# 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. Two things are not, and they are first.

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

---

## 1. 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`.

---

## 2. `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\includes\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
```

---

## 3. 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+)\('`

---

## 4. 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.

---

## 5. 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.

---

## 6. 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`.

---

## 7. 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.

---

## 8. `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);
 }
```

---

## 9. 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.

---

## Order of work

1. Dump your routes and keep the output.
2. Declare every module `readonly`; move any array property behind an object or a `const`.
3. Re-parent off `apiModule`/`displayModule`; fix the constructor.
4. Replace `routes(RouteManager)` with `routes()`, and each helper with a verb method or group.
5. Give every pattern its leading slash and its full path.
6. Move permissions onto the route as middleware.
7. Replace `getRoutePattern()` with `RouteUrls`.
8. Diff the route dump. Nothing should have moved.
9. Boot the app. Readonly and pattern failures surface here, not before.
