# Upgrading to io 6

io 6 rewrites how a module registers routes. Everything a route needed to say — its path, its
verb, what gates it, which module it belongs to — used to be spread across a base route it
inherited, a prefix from whichever helper it called, a Slim route argument, and a parallel
route table. It is now said in one place, on the route.

Most of this is mechanical, and §1 is the most mechanical of all — but nothing else runs
until it is done, so it comes first.

**Read §2 and §3 before you start.** They fail at runtime rather than at lint, which means a
green deploy and a broken boot.

---

## 1. Every io namespace changed

io's namespaces are normalised to PSR-4 under `Apexl\`, and io-cache and io-logger folded into
io while it happened. **There is no alias layer and nothing is compatible** — a file importing an
old name fatals the moment it loads, so the whole app moves at once, before anything below can
be tested.

| was | now |
|---|---|
| `apexl\Io\includes\Module`, `…\includes\Entity` | `Apexl\Io\Module`, `Apexl\Io\Entity` |
| `apexl\Io\services\FilePaths` | `Apexl\Io\Services\FilePaths` |
| `apexl\Io\collections\`, `enums\`, `interfaces\`, `Traits\` | `Apexl\Io\Collections\`, `Enums\`, `Interfaces\`, `Traits\` |
| `apexl\Io\dto\Relationship\HasOne` | `Apexl\Io\Dtos\Relationship\HasOne` |
| `apexl\Io\modules\user\…` | `Apexl\Io\User\…` |
| `apexl\Io\modules\menu\attributes\MenuEntry` | `Apexl\Io\Menu\Attributes\MenuEntry` |
| `apexl\Io\modules\tabulator\…` | `Apexl\Io\Tabulator\…` |
| `apexl\Io\TestInfrastructure\…` | `Apexl\Io\TestInfrastructure\…` — the root only |
| `apexl\Config\…` | `Apexl\Config\…` — the root only |

**`dto` became `Dtos`** — the segment changed, not just its case. A case-insensitive
find-and-replace gets everything else and leaves that one broken.

Every satellite package maps **`Apexl\Io\<Package>\` to its own `src/`**: `Apexl\Io\Menu\`,
`Apexl\Io\UserTabulator\`, `Apexl\Io\MirrorS3\`. `Apexl\Io\Modules\` is io's own, holding
the four modules it ships — a satellite must not write into it.

### The global functions moved too, and they fail later than the imports

They are namespaced now, so an unqualified call is not a missing class at boot: it is a
`Call to undefined function` on whichever line finally runs, which for an error path can be
long after deploy.

```php
-logger('sync')->error($e->getMessage());       // Call to undefined function <your namespace>\logger()
+\Apexl\Io\Logger\logger('sync')->error($e->getMessage());
```

`container()`, `env()` and `assertInstanceOf()` are `Apexl\Io\container()` and friends;
`logger()` is `Apexl\Io\Logger\logger()`, since io-logger folded in. **Find them:**
`rg -P '(?<![\\>\w])(logger|container|env)\('` — the lookbehind needs `-P`, or a bare
`rg 'logger\('` also lists the calls you have already qualified.

### io-cache and io-logger are dead ends

Both are now part of io. Drop them from your `composer.json`; io requires neither, and the tags
they carry on the io 6 line install code nothing points at.

---

## 2. Modules are `readonly`, and PHP makes that viral

`Module` is `abstract readonly class`. **A non-readonly class cannot extend a readonly one**, so
every module in your app must be declared `readonly` too.

```php
-final class ReportsModule extends Module
+final readonly class ReportsModule extends Module
```

### The part that bites

A `readonly` class makes every property implicitly readonly, and a readonly property may not be
written twice. **Writing a key into an array counts as writing the property.**

```php
readonly class ReportsModule extends Module
{
    private array $cache;                       // declares fine

    private function meta(string $entity): Meta
    {
        return $this->cache[$entity] ??= Meta::for($entity);   // Cannot indirectly modify
    }                                                          // readonly property
}
```

That is not a compile error. The class loads, the first call succeeds, and the **second** call
raises `Cannot indirectly modify readonly property`. For a module memoising per entity, that is
boot — in whichever app happens to register two of something.

So: **no module should hold an `array`.** If it is constant data, make it a `const`. If it is a
cache, put it behind an object — a readonly property may not be *reassigned*, but what it points
at may be mutated freely:

```php
final class MetaCache
{
    /** @var array<class-string, Meta> */
    private array $entries = [];

    public function for(string $entity): Meta
    {
        return $this->entries[$entity] ??= Meta::for($entity);
    }
}

final readonly class ReportsModule extends Module
{
    private MetaCache $cache;

    public function __construct(ConfigurationInterface $config, App $app)
    {
        parent::__construct($config, $app);

        $this->cache = new MetaCache();
    }
}
```

A single write-once property is fine as it stands — `??=` works on an uninitialised readonly
property — but it may not carry a default:

```php
-private ?Collection $entities = null;    // Readonly property cannot have default value
+private ?Collection $entities;           // assigned once by ??=, which is legal
```

**Find them:** `rg 'private array \$|protected array \$' <your module files>`

io ships a test asserting no module holds an array. Copy it if you want the same guard:
`tests/FeatureTests/ModulesAreImmutableTest.php`.

---

## 3. `apiModule` and `displayModule` are gone, and so is the base route

Both classes are deleted. Extend `Module` directly.

```php
-use apexl\Io\recommendedIncludes\apiModule;
-final class ReportsModule extends apiModule
+use Apexl\Io\Module;
+final readonly class ReportsModule extends Module
```

They existed to set a base route that every `addRoute()` call was silently prefixed with. That is
gone: **`setBaseRoute()` no longer exists, and a route's path is the path you give it.**

This is the change most likely to move a URL without telling you. Before, the path depended on
the module's class name, its parent class, and the configured API version — none of which were
visible where the route was written. Now:

```php
-// under apiModule, this registered /api/v1/reports/data/monthly
-$this->addDataRoute(HttpMethod::GET, 'reports.monthly', 'monthly', MonthlyController::class);

+$this->apiGroup('reports', function (RouteCollectorProxy $routes): void {
+    $routes->get('/data/monthly', MonthlyController::class)->setName('reports.monthly');
+});
```

`apiGroup($module, $routes)` prefixes with `/api/v{version}/{module}` — the version still comes
from `api.version`, because changing it has to move every API route at once. Everything else about
a prefix is written where you can read it.

**Before you migrate a module, dump its routes and keep the output.** Compare afterwards: paths
should be byte-identical unless you meant to change them.

```
daedalus routes:dump > before.txt
```

---

## 4. Registering routes

`RouteManager` is gone. `routes()` takes no argument, and `App` is `private` on `Module` — the
verb methods are the only way in.

```php
-public function routes(RouteManager $routeManager): void
+public function routes(): void
 {
-    $this->addRoute($routeManager, HttpMethod::GET, 'reports.index', 'index', IndexController::class);
+    $this->get('/reports/index', IndexController::class)->setName('reports.index');
 }
```

| gone | use |
|---|---|
| `addRoute()`, `rootRoute()` | `get()` `post()` `put()` `patch()` `delete()` `options()` |
| `addActionRoute()`, `addDataRoute()`, `addDisplayRoute()` | a `group()` carrying the prefix |
| `addProtectedActionRoute()` and the rest of the `addProtected*` family | the verb, then `->add(new RoutePermissionMiddleware(…))` |
| `addProtectedEntityRoutes()`, `setEntityPatterns()`, `buildEntityRoutes()` | write the routes out |
| `$this->app->get(…)` | `$this->get(…)` — `App` is private |

A route may answer several verbs:

```php
$this->route([HttpMethod::GET, HttpMethod::POST], '/callback', CallbackController::class)
    ->setName('reports.callback');
```

Routes need not be named. Name them if anything looks them up — the menu, `routes:dump` and
`RouteUrls` all do — by chaining `->setName()`, which reads the same inside a group or out.

**Naming a route now takes the name from any earlier route holding it.** Slim keeps both and
resolves a name to the first, so overriding a route used to leave the original answering; that is
fixed, and it is why `setName()` is where it happens.

**Find them:** `rg '\$this->(addRoute|rootRoute|addActionRoute|addDataRoute|addDisplayRoute|addProtected\w+)\('`

---

## 5. Patterns must start with `/`, and verbs are `HttpMethod`

Slim normalises neither, and both failures are silent. io now refuses them at registration.

```php
$this->get('reports', C::class);      // RoutePatternException: no request path can match it
```

Inside a group it is worse than useless — `RouteCollectorProxy` concatenates, so a group on
`/admin` holding `'users'` registers `/adminusers`, a real route at a path nobody meant, while
`/admin/users` 404s. The guard catches both.

If a pattern of yours relied on a helper adding the slash, add it.

---

## 6. Permissions are middleware on the route

Slim deprecated route arguments in 4.14.1, they hold strings only — which is why permissions used
to be `serialize()`d and `unserialize()`d on every request — and they are merged into
`getArguments()`, where io-validation spreads them into the data a request is validated against.

```php
-$this->addProtectedDataRoute(HttpMethod::GET, 'reports.monthly', 'monthly', C::class, Reports::READ);
+$this->get('/data/monthly', C::class)
+    ->setName('reports.monthly')
+    ->add(new RoutePermissionMiddleware(Reports::READ));
```

Shared by a group of routes? Put it on the group:

```php
$this->group('/admin', function (RouteCollectorProxy $routes): void {
    $routes->get('/users', UsersController::class)->setName('admin.users');
})->add(new RoutePermissionMiddleware(Admin::ADMINISTER));
```

Both are readable — routes are `IoRoute`, which records what is attached to it, and reads its
groups' middleware too:

```php
$route->getMiddleware()->find(DeclaresRoutePermission::class)?->getPermission();
```

**Attach permissions as instances, never as a class-string.** `->add(SomeMiddleware::class)` takes
Slim's deferred path, which stores only the name — there is nowhere for the permission to live, and
nothing can read it back, so the menu will offer a page the caller is then refused.

`Common::ALLOW_ALL` is what `isAllowed()` answers true to unconditionally. Attaching it runs a gate
to arrive where no gate arrives — **attach nothing instead.** Note that the old `addProtected*`
helpers defaulted to it, so a call that omitted the permission argument produced an open route;
check what you actually meant before carrying it over.

---

## 7. Reading routes back: `RouteUrls`

`RouteManager::getRoutePattern()`, the `Route` object and the route table behind it are gone.

```php
-$routeManager->getRoutePattern('reports.monthly', ['id' => 4])
+$routeUrls->url('reports.monthly', ['id' => 4])            // a finished URL
+$routeUrls->pattern('reports.monthly')                     // placeholders intact, for the front end
+$routeUrls->pattern('reports.monthly', ['companyId' => 2]) // partly filled
+$routeUrls->absolute()->url('reports.monthly', ['id' => 4])
```

`url()` **refuses** to build a URL it has no value for. The method it replaces deleted placeholders
it could not fill, so a forgotten argument produced a plausible URL pointing at a different route.
If you were relying on that — `getRoutePattern($name, [], false)` and friends — the call site
wants reviewing, not translating. Optional segments still drop out: `/login[/{type}]` with nothing
to say is `/login`.

---

## 8. Hooks declare their keys statically

`AbstractHook` still works and is deprecated. Implementing `HookInterface` lets boot read a hook's
keys without constructing it — it has to ask every hook in every module which points it wants,
long before knowing whether any will fire.

```php
-final class AddMiddleware implements AbstractHook
+final readonly class AddMiddleware implements HookInterface
 {
-    public function hooks(): array
+    public static function hooks(): array
     {
         return [MiddlewareCollector::HOOK__COLLECT];
     }
```

The instance is still built lazily when the hook fires; constructor injection is unchanged.

**The `routePreBuild` hook is gone.** It fired on a route table that no longer exists.

---

## 9. `IoModuleInterface` is one method

```php
interface IoModuleInterface
{
    public function getName(): string;
}
```

`schema()` is no longer required, and `Module::install()`, `uninstall()` and `requiresInstall()`
are removed — nothing had called any of them in a long time, and migrations own the schema. Delete
your overrides.

`Module::__construct()` also no longer takes `InstallChecker`; no module ever read it. The service
is unchanged if you inject it yourself.

```php
-public function __construct(InstallChecker $installChecker, ConfigurationInterface $config, App $app)
-{
-    parent::__construct($installChecker, $config, $app);
+public function __construct(ConfigurationInterface $config, App $app)
+{
+    parent::__construct($config, $app);
 }
```

---

## 10. Config: `phinx.php` is excluded by default

The config loader now skips `phinx.php`. It is a phinx CLI config that happened to sit in a scanned
directory, and loading it built a second container and re-read every config file, per request.

Nothing reads `phinx.*` at runtime, but if you do, set `CONFIG__EXCLUDE=` to load everything, or
list other filenames to skip.

---

---

## 11. Rows that name a class you just renamed

A rename does not reach the copies of a class name already written to the database, and those
fail at read time rather than at boot.

**Permission grants are serialised enum cases** — `permissions__roles.permission` and
`permissions__users.permission` hold `E:<length>:"<class>:<CASE>";`. `unserialize()` *raises* on
a missing class rather than returning false, and `PermissionCollection` maps it over every row,
so a single stranded grant answers 500 for every page that builds a menu.

io-user 6.0.2, io-admin 4.0.1 and io-file 6.0.1 each ship the migration for the enums they own,
and phinx globs `vendor/apexl/*/src/db/migrations`, so **running your migrations is the whole of
the fix** for io's own permissions.

What is still yours to do:

- **Your own permission enums**, if you renamed any alongside io's. Write the same migration,
  and mind the two ways it silently does nothing: the `E:<length>:` prefix has to be
  **recomputed** (leave the old one and `unserialize()` reads past the end of the value), and
  the rows cannot be found with `LIKE` — it reads the backslashes in a class name as its own
  escapes, so the pattern matches nothing and the migration reports success having changed
  no rows. Select the values and filter in PHP.
- **The `modules` table**, if you bind `ModuleFinderInterface` to `DbModuleFinder`: every row
  names a module class, and `ModuleManager` resolves each one from the container.
- **io-basic-module 4.0.0** renamed its own modules to `Apexl\Io\BasicModule\BasicApi\…` and
  `…\BasicDisplay\…`; if you list either, update the row.

## Order of work

1. Rewrite the io namespaces, imports and global function calls. Nothing below can run, or
   even be tested, until this is done.
2. Dump your routes and keep the output.
3. Declare every module `readonly`; move any array property behind an object or a `const`.
4. Re-parent off `apiModule`/`displayModule`; fix the constructor.
5. Replace `routes(RouteManager)` with `routes()`, and each helper with a verb method or group.
6. Give every pattern its leading slash and its full path.
7. Move permissions onto the route as middleware.
8. Replace `getRoutePattern()` with `RouteUrls`.
9. Diff the route dump. Nothing should have moved.
10. Boot the app. Readonly and pattern failures surface here, not before.
11. Run your migrations, then load a page that builds a menu — stranded permission grants
    surface there and nowhere earlier.
