Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
18 changes: 18 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -5,6 +5,24 @@ All notable changes to this project are documented in this file.
The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.1.0/),
and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html).

## [3.3.2]

### Fixed

- Route controllers are now built by the application container. Constructor
dependencies follow the same rules as action-method parameters: bindings,
singletons, interfaces bound in the container, and default values for scalar
parameters. Previously `Route` built controllers with its own injector, which
ignored the container. A singleton injected into the constructor was then a
different object from the same singleton injected into the action, a bound
interface failed with `Class "..." does not exist`, a scalar parameter failed
even when it had a default, and unbound services were shared between unrelated
controllers. A controller is now built fresh on each dispatch; bind it with
`app()->singleton()` to share one instance. `Route::make()` keeps its signature
and delegates to the container. The protected static cache it used is removed:
`Route::$instances`, `cache()`, `is_cached()` and `get_cached()`. See
`docs/routing-examples.md`.

## [3.3.1]

### Added
Expand Down
31 changes: 31 additions & 0 deletions docs/routing-examples.md
Original file line number Diff line number Diff line change
Expand Up @@ -100,6 +100,37 @@ public function show(EventShowRequest $request, Event $event, EventService $serv

**Expected:** `GET /wp-json/kirki/v1/events/42` resolves `{event}` from the route, authorizes/validates via the Form Request, and returns JSON.

### Controller constructor injection

Controllers are built by the application container, the same way as `app()->make()`. A constructor therefore gets bindings, singletons, interfaces bound in the container, and the default value of any scalar parameter, just as action-method parameters do:

```php
use function Framework\app;

// In a service provider
app()->singleton(Cart::class, fn () => new Cart());
app()->bind(PaymentGateway::class, fn () => new StripeGateway());

class CartController
{
protected $cart;

public function __construct(Cart $cart, PaymentGateway $gateway, int $limit = 10)
{
$this->cart = $cart;
}

public function add(CartAddRequest $request, Cart $cart)
{
// $this->cart and $cart are the same singleton
}
}
```

A controller is built fresh on every dispatch. To share one instance across dispatches in the same PHP process (for example REST batch requests), bind the controller itself with `app()->singleton(CartController::class)`.

**Expected:** The constructor and the action method receive the same `Cart` instance, `$gateway` is a `StripeGateway`, and `$limit` is `10`.

---


Expand Down
29 changes: 29 additions & 0 deletions example/app/Http/Controllers/TestController.php
Original file line number Diff line number Diff line change
@@ -0,0 +1,29 @@
<?php

namespace Example\App\Http\Controllers;

use Example\App\Models\Blog;
use Example\App\Services\TestService;
use Framework\Http\Request;
use Framework\Supports\Facades\Guard;

use function Framework\response;

class TestController
{
protected TestService $service;

public function __construct(TestService $service)
{
$this->service = $service;
$this->service->increase();
}

public function get(Request $request, TestService $service)
{
return response()->json([
'message' => 'Test Controller Loaded',
'check' => $this->service->value() === $service->value()
]);
}
}
2 changes: 2 additions & 0 deletions example/app/Providers/TestServiceProvider.php
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,7 @@

namespace Example\App\Providers;

use Example\App\Services\TestService;
use Framework\Http\Request;
use Framework\RateLimiting\Limit;
use Framework\ServiceProvider;
Expand All @@ -14,6 +15,7 @@ class TestServiceProvider extends ServiceProvider
public function register()
{
$config = config('app.name');
$this->app->singleton(TestService::class);
}

public function boot()
Expand Down
23 changes: 23 additions & 0 deletions example/app/Services/TestService.php
Original file line number Diff line number Diff line change
@@ -0,0 +1,23 @@
<?php

namespace Example\App\Services;

class TestService
{
protected $value = 0;

public function value()
{
return $this->value;
}

public function increase(int $amount = 1)
{
$this->value += $amount;
}

public function decrease(int $amount = 1)
{
$this->value -= $amount;
}
}
4 changes: 4 additions & 0 deletions example/routes/api.php
Original file line number Diff line number Diff line change
Expand Up @@ -5,6 +5,7 @@
use Example\App\Http\Controllers\BlogController;
use Example\App\Http\Controllers\EventsController;
use Example\App\Http\Controllers\SpeakersController;
use Example\App\Http\Controllers\TestController;
use Example\App\Http\Requests\ExampleRequest;
use Example\App\Models\Blog;
use Example\App\Models\Event;
Expand Down Expand Up @@ -90,3 +91,6 @@
'cached' => Cache::get('sample'),
]);
})->throttle(5, 1);


Route::get('/new-route', [TestController::class, 'get']);
Original file line number Diff line number Diff line change
@@ -0,0 +1,2 @@
schema: spec-driven
created: 2026-11-12
Original file line number Diff line number Diff line change
@@ -0,0 +1,74 @@
## Context

See proposal.md (Why) for the defect. The relevant code paths are:

- `Route::resolve_controller()` (`src/Route.php:1898`) is the only place that builds a controller. It runs once per dispatch, from `dispatch_with_request()`, for both REST and site routes. The permission callback and the middleware pipeline never build the controller.
- `Route::make()` (`src/Route.php:1198`) is a reflection injector with its own circular-dependency check. It memoizes every instance it builds, controllers and their dependencies alike, in `protected static $instances` through `cache()`, `is_cached()` and `get_cached()`.
- `Route::resolve_abstracts()` (`src/Route.php:1478`) resolves action-method dependencies through `app()->make()`.
- `Container::autowire()` (`src/Container.php`) already does everything `Route::make()` does, plus the parts it gets wrong: bindings, singletons, aliases, scalar defaults, a "not instantiable" check, and circular-dependency detection with the full chain in the message.

## Goals / Non-Goals

**Goals:**

- Make the container the single dependency injector for controller construction.
- Keep `Route::make()` callable and overridable by subclasses with an unchanged signature.

**Non-Goals:**

- Changing how `Container` resolves dependencies. In particular, `?SomeInterface $x = null` with an unbound interface still throws instead of receiving `null`. That happened under `Route::make()` too, so this change makes nothing worse. Fixing it changes resolution for every `app()->make()` caller and belongs in its own change.
- Memoizing controllers per `Route` object, which is the Laravel approach. Nothing here needs it; see Decision 1.
- Changing how action-method or closure dependencies are categorized or resolved.

## Decisions

### 1. Controllers are built fresh per dispatch, not cached

`resolve_controller()` gets the controller from the container on every dispatch. A consumer who wants one shared instance binds the controller, or the dependency, with `app()->singleton()`.

**Why:** the static cache never saves work inside a single request, because the controller is built exactly once per dispatch either way. A process sees more than one dispatch only for REST `batch/v1`, `rest_do_request()`, and block-editor preloads. There the static cache is a hazard, because any per-request state on the controller would leak into the next sub-request. kirki-ecommerce's 28 controllers write properties only in their constructors, so a fresh instance behaves identically for them. The cache also forces `TestCase::reset_route_state()` to clear `Route::$instances` between tests.

**Alternatives considered:**

- *Memoize on the `Route` instance (Laravel).* This adds state and a second lifetime rule for no current benefit.
- *Keep the process-wide static cache, filled by the container.* This keeps the cross-controller sharing (symptom 4) and the hidden second lifetime the report complains about.

### 2. `Route::make()` becomes a one-line call to the container; the cache members are deleted

```php
protected function make(string $abstract, array $resolving = [])
{
return app()->make($abstract);
}
```

`$resolving` stays in the signature so that a subclass that overrides or calls `make()` keeps a compatible signature. It goes unused, and the docblock says it is kept for compatibility. `resolve_controller()` keeps calling `$this->make($controller)`, so `make()` stays the one method a subclass overrides to change how controllers are built.

`protected static $instances`, `cache()`, `is_cached()` and `get_cached()` are deleted. With nothing filling the cache they would be dead code, and keeping them invites someone to wire the second cache back in. The `use ReflectionClass;` import becomes unused and is removed. `Exception`, `throw_if` and `throw_unless` are still used elsewhere in `Route.php` and stay.

**Alternatives considered:**

- *Delete `make()` too.* Any subclass that calls `$this->make()` would hit a fatal error. The one-line method is cheap compatibility.
- *Keep everything and mark it deprecated.* This leaves the broken injector reachable, and a deprecated cache that nothing fills would be misleading.

### 3. The existing `class_exists($controller)` guard stays

`resolve_controller()` already throws `InvalidRoutActionException('Controller X not found')` before calling `make()`. That message is more useful at the routing layer than the container's reflection error, so it stays unchanged.

### 4. Tests

- Replace `test_make_resolves_constructor_dependencies_and_caches_instances` with a test that two `make()` calls return two distinct instances. The old test asserts the behaviour being removed.
- Add cases for each spec scenario: a constructor and action method that share a singleton, a bound interface, a closure-built service with a scalar constructor, a scalar default, a singleton-bound controller reused across two dispatches, and an unbound dependency that is not shared across controllers.
- `test_make_detects_circular_dependencies` keeps working unchanged. The container's message starts with `Circular dependency detected:`.
- Remove the `'instances' => []` entry from `TestCase::reset_route_state()`. The `hasProperty()` guard would skip it anyway, but it would be a stale reference.

## Risks / Trade-offs

- [A consumer subclass reads `Route::$instances` or calls the cache helpers] → It fails loudly with an undefined property or method, not silently. No known consumer does this. The CHANGELOG entry names the removed members.
- [A consumer controller relied on state persisting across dispatches in one process] → It only matters for batch or internal REST dispatch. The fix is to bind the controller as a singleton, and the CHANGELOG entry says so. kirki-ecommerce controllers are stateless.
- [Unbound services that were accidentally shared between controllers are now separate] → This is the intended fix (symptom 4). Anything that must be shared should be a container singleton.
- [Error messages change for bad constructors: "not instantiable" instead of "is abstract" / "does not exist"] → Only error text changes, and the container's messages are more accurate.

## Migration Plan

Ships as 3.3.2 under `### Fixed`. Consumers update the Composer dependency and re-scope. No code change is needed unless a subclass touches the removed cache members. Rollback means reverting to 3.3.1.
Original file line number Diff line number Diff line change
@@ -0,0 +1,36 @@
## Why

`Route::make()` (`src/Route.php:1198`) builds controllers with its own reflection injector and a process-wide static cache. It never asks the application container. Action-method parameters, by contrast, go through `app()->make()`. So a controller's constructor follows different rules from everything else in the framework:

- A container singleton injected into the constructor is a different object from the same singleton injected into the action method. Nothing errors, so the bug is silent.
- An interface bound in the container fails with `Class "X" does not exist`.
- A scalar parameter fails with `must be a class type, not a built-in type`, even when it has a default value.
- Unbound services are shared between unrelated controllers through `Route::$instances`.

All four were reproduced against the current code. kirki-ecommerce is one step away from hitting this. `ShippingService` is a container singleton built from `array $shipping_settings`, and `OrderCalculationContextService` injects it. Any controller constructor that reaches it would crash.

## What Changes

- Controllers are built by the application container on every dispatch, so constructor dependencies follow the same rules as `app()->make()`: bindings, singletons, bound interfaces, scalar defaults, and circular-dependency detection.
- `Route::make()` keeps its signature and delegates to `app()->make()`. `resolve_controller()` keeps calling `$this->make()`, so subclasses still have one method to override.
- **BREAKING (narrow):** the protected static cache is removed: `Route::$instances`, `cache()`, `is_cached()`, `get_cached()`. Only a subclass of `Route` that touches these members is affected.
- Behaviour change: a controller is no longer reused for the whole process. It is built fresh per dispatch, and so are its unbound dependencies. Consumers who want one shared instance bind it as a singleton.
- CHANGELOG `## [3.3.2]` → `### Fixed` entry; a "Controller constructor injection" subsection in `docs/routing-examples.md`.

## Capabilities

### New Capabilities

- `route-controller-resolution`: how a route builds its controller instance: through the application container, fresh per dispatch, with the same resolution rules as action-method dependencies.

### Modified Capabilities

None. No existing spec covers routing.

## Impact

- **Code:** `src/Route.php`: `make()`, the static `$instances` property, and the `cache()`, `is_cached()` and `get_cached()` methods.
- **Tests:** `tests/Unit/RouteDiTest.php` (replace the caching test, add container-resolution cases); `tests/Unit/TestCase.php` (drop the `'instances'` reset).
- **Docs:** `docs/routing-examples.md`, `CHANGELOG.md`.
- **Consumers:** kirki-ecommerce controllers need no change. They inject only concrete services and keep no state between dispatches. Controller constructors become safe for container-bound services such as `ShippingService`, `MoneyManager` and `PaymentManager`.
- **Scoping:** none. `app()->make()` takes the class-string it is given, so nothing new is built from string literals.
Original file line number Diff line number Diff line change
@@ -0,0 +1,62 @@
## Purpose

Defines how a route builds the controller instance for a controller action, so that controller
constructors receive dependencies under the same rules as action-method parameters and every other
service the application container resolves.

## ADDED Requirements

### Requirement: Controllers are resolved through the application container
When a route with a `[Controller::class, 'method']` action is dispatched, on a REST or a site route,
the framework SHALL obtain the controller instance from the application container. The controller's
constructor dependencies SHALL be resolved by the same rules as `app()->make()`. A dependency that
is bound or registered as a singleton SHALL receive the bound instance, and an interface with a
binding SHALL receive the bound implementation.

#### Scenario: Constructor and action method share a container singleton
- **WHEN** a class is registered as a singleton in the container, and a controller injects that class both in its constructor and in the dispatched action method
- **THEN** the constructor and the action method receive the same instance, and it is the container's singleton

#### Scenario: Constructor receives a bound interface
- **WHEN** a controller constructor type-hints an interface that has a binding in the container
- **THEN** the controller is built with the bound implementation, and no "class does not exist" error is raised

#### Scenario: Constructor receives a container-built service
- **WHEN** a controller constructor type-hints a class that is bound with a factory closure, whose own constructor takes non-class arguments supplied by that closure
- **THEN** the controller is built with the instance the closure returns

### Requirement: Scalar constructor parameters with defaults are accepted
A controller constructor parameter with a built-in type, or with no type, SHALL NOT cause
resolution to fail when it declares a default value. The default value SHALL be passed. A
parameter of that kind with no default SHALL fail with the container's error for an unresolvable
primitive.

#### Scenario: Scalar parameter with a default
- **WHEN** a controller constructor is `__construct(Logger $logger, int $limit = 10)`
- **THEN** the controller is built with a resolved `Logger` and `$limit` equal to `10`

### Requirement: A controller is built fresh for each dispatch
The framework SHALL NOT keep its own cache of controller instances or of their constructor
dependencies. Each dispatch SHALL obtain the controller from the container. That SHALL yield a new
instance unless the consumer bound the controller, or the dependency, as a singleton. Two different
controllers SHALL share an unbound dependency only if the container would share it.

#### Scenario: Two dispatches build two controllers
- **WHEN** the same controller route is dispatched twice in one PHP process, and the controller is not bound as a singleton
- **THEN** each dispatch receives a distinct controller instance

#### Scenario: Singleton-bound controller is reused
- **WHEN** a controller class is registered as a singleton in the container, and its route is dispatched twice in one PHP process
- **THEN** both dispatches receive the same controller instance

#### Scenario: Unbound dependency is not shared across controllers
- **WHEN** two different controllers each inject the same unbound concrete service in their constructors
- **THEN** each controller receives its own instance of that service

### Requirement: Circular constructor dependencies are reported
Resolving a controller whose constructor dependencies form a cycle SHALL fail with an exception
whose message identifies the circular dependency. It SHALL NOT recurse without limit.

#### Scenario: Circular dependency in a controller's constructor graph
- **WHEN** a controller depends on class A, A depends on B, and B depends on A
- **THEN** resolution throws an exception whose message contains "Circular dependency detected"
Loading
Loading