From c795cb92d5c609157067ceb523f4cfbda9e196ea Mon Sep 17 00:00:00 2001 From: Mark Date: Mon, 20 Jul 2026 12:05:31 +0200 Subject: [PATCH 1/2] feat(router): preload model relations during route binding --- docs/1-essentials/01-routing.md | 21 +++++++++++++ packages/database/src/IsDatabaseModel.php | 4 +-- .../router/src/RouteBindingInitializer.php | 9 +++++- packages/router/src/WithRelations.php | 19 ++++++++++++ .../Fixtures/Modules/Books/BookController.php | 7 +++++ tests/Integration/Route/RouterTest.php | 31 +++++++++++++++++++ 6 files changed, 88 insertions(+), 3 deletions(-) create mode 100644 packages/router/src/WithRelations.php diff --git a/docs/1-essentials/01-routing.md b/docs/1-essentials/01-routing.md index 37e28d35bc..e6857240ca 100644 --- a/docs/1-essentials/01-routing.md +++ b/docs/1-essentials/01-routing.md @@ -163,6 +163,27 @@ final class AircraftController } ``` +Database models may preload relations as part of the route binding query using the {b`#[Tempest\Router\WithRelations]`} parameter attribute: + +```php app/AircraftController.php +use Tempest\Router\Get; +use Tempest\Router\WithRelations; +use Tempest\Http\Response; +use Tempest\Http\Responses\Ok; +use App\Aircraft; + +final class AircraftController +{ + #[Get('/aircraft/{aircraft}')] + public function show( + #[WithRelations('manufacturer', 'airline')] + Aircraft $aircraft, + ): Response { + return new Ok($aircraft->manufacturer->name); + } +} +``` + Route binding can be enabled for any class that implements the {b`Tempest\Router\Bindable`} interface, which requires a static `resolve()` method responsible for returning the correct instance. ```php diff --git a/packages/database/src/IsDatabaseModel.php b/packages/database/src/IsDatabaseModel.php index f94a1dd774..93a7442303 100644 --- a/packages/database/src/IsDatabaseModel.php +++ b/packages/database/src/IsDatabaseModel.php @@ -137,10 +137,10 @@ public static function findById(string|int|PrimaryKey $id): ?self /** * Finds a model instance by its ID. Use through {@see \Tempest\Router\Bindable}. */ - public static function resolve(string $input): ?static + public static function resolve(string $input, array $relations = []): ?static { // @phpstan-ignore-next-line - return self::queryBuilder()->resolve($input); + return self::queryBuilder()->get($input, $relations); } /** diff --git a/packages/router/src/RouteBindingInitializer.php b/packages/router/src/RouteBindingInitializer.php index 64673edf65..4240dc4055 100644 --- a/packages/router/src/RouteBindingInitializer.php +++ b/packages/router/src/RouteBindingInitializer.php @@ -37,7 +37,14 @@ public function initialize(ClassReflector $class, string|UnitEnum|null $tag, Con throw new RouteBindingFailed(); } - $object = $class->callStatic('resolve', $matchedRoute->params[$parameter->getName()]); + $withRelations = $parameter->getAttribute(WithRelations::class); + + $input = $matchedRoute->params[$parameter->getName()]; + + $object = match ($withRelations) { + null => $class->callStatic('resolve', $input), + default => $class->callStatic('resolve', $input, relations: $withRelations->relations), + }; if ($object === null) { throw new RouteBindingFailed(); diff --git a/packages/router/src/WithRelations.php b/packages/router/src/WithRelations.php new file mode 100644 index 0000000000..e8719a0900 --- /dev/null +++ b/packages/router/src/WithRelations.php @@ -0,0 +1,19 @@ + */ + public array $relations; + + public function __construct(string ...$relations) + { + $this->relations = $relations; + } +} diff --git a/tests/Fixtures/Modules/Books/BookController.php b/tests/Fixtures/Modules/Books/BookController.php index 039f1bb39f..560a1d29f2 100644 --- a/tests/Fixtures/Modules/Books/BookController.php +++ b/tests/Fixtures/Modules/Books/BookController.php @@ -10,6 +10,7 @@ use Tempest\Http\Responses\Redirect; use Tempest\Router\Get; use Tempest\Router\Post; +use Tempest\Router\WithRelations; use Tests\Tempest\Fixtures\Modules\Books\Models\Book; use Tests\Tempest\Fixtures\Modules\Books\Requests\CreateBookRequest; @@ -24,6 +25,12 @@ public function show(Book $book): Response return new Ok($book->title); } + #[Get('/books-with-relations/{book}')] + public function showWithRelations(#[WithRelations('author', 'chapters')] Book $book): Response + { + return new Ok(sprintf('%s:%d', $book->author->name, count($book->chapters))); + } + #[Post('/books')] public function store(CreateBookRequest $request): Response { diff --git a/tests/Integration/Route/RouterTest.php b/tests/Integration/Route/RouterTest.php index 89ab6e63f9..cbd4f608b3 100644 --- a/tests/Integration/Route/RouterTest.php +++ b/tests/Integration/Route/RouterTest.php @@ -23,11 +23,14 @@ use Tempest\Router\SecFetchSite; use Tests\Tempest\Fixtures\Controllers\TestGlobalMiddleware; use Tests\Tempest\Fixtures\Controllers\TestMiddleware; +use Tests\Tempest\Fixtures\Events\QueryLogger; use Tests\Tempest\Fixtures\Migrations\CreateAuthorTable; use Tests\Tempest\Fixtures\Migrations\CreateBookTable; +use Tests\Tempest\Fixtures\Migrations\CreateChapterTable; use Tests\Tempest\Fixtures\Migrations\CreatePublishersTable; use Tests\Tempest\Fixtures\Modules\Books\Models\Author; use Tests\Tempest\Fixtures\Modules\Books\Models\Book; +use Tests\Tempest\Fixtures\Modules\Books\Models\Chapter; use Tests\Tempest\Integration\FrameworkIntegrationTestCase; use Tests\Tempest\Integration\Route\Fixtures\HeadController; use Tests\Tempest\Integration\Route\Fixtures\Http500Controller; @@ -114,6 +117,34 @@ public function route_binding(): void $this->assertSame('Test', $response->body); } + #[Test] + public function route_binding_with_relations(): void + { + $this->database->migrate( + CreateMigrationsTable::class, + CreatePublishersTable::class, + CreateAuthorTable::class, + CreateBookTable::class, + CreateChapterTable::class, + ); + + $book = Book::create( + title: 'Test', + author: new Author(name: 'Brent'), + ); + + Chapter::create(title: 'Chapter', book: $book); + + QueryLogger::reset(); + + $this->http + ->get('/books-with-relations/1') + ->assertOk() + ->assertSee('Brent:1'); + + $this->assertCount(1, QueryLogger::$queries); + } + #[Test] public function route_binding_with_nonexistent_model_returns_404(): void { From 3cd214f6e5244de3b02ac7ca2694496227df456f Mon Sep 17 00:00:00 2001 From: Mark Date: Tue, 21 Jul 2026 11:53:21 +0200 Subject: [PATCH 2/2] fix(router): reject unsupported relation bindings --- .../RouteBindingDidNotSupportRelations.php | 19 ++++++++ .../router/src/RouteBindingInitializer.php | 9 ++++ tests/Integration/Route/RouterTest.php | 45 +++++++++++++++++++ 3 files changed, 73 insertions(+) create mode 100644 packages/router/src/Exceptions/RouteBindingDidNotSupportRelations.php diff --git a/packages/router/src/Exceptions/RouteBindingDidNotSupportRelations.php b/packages/router/src/Exceptions/RouteBindingDidNotSupportRelations.php new file mode 100644 index 0000000000..17cd30126d --- /dev/null +++ b/packages/router/src/Exceptions/RouteBindingDidNotSupportRelations.php @@ -0,0 +1,19 @@ +getAttribute(WithRelations::class); + if ($withRelations !== null) { + $resolve = $class->getMethod('resolve'); + + if ($resolve->getParameter('relations') === null && ! $resolve->getReflection()->isVariadic()) { + throw new RouteBindingDidNotSupportRelations($class->getName()); + } + } + $input = $matchedRoute->params[$parameter->getName()]; $object = match ($withRelations) { diff --git a/tests/Integration/Route/RouterTest.php b/tests/Integration/Route/RouterTest.php index cbd4f608b3..1b06897d6b 100644 --- a/tests/Integration/Route/RouterTest.php +++ b/tests/Integration/Route/RouterTest.php @@ -16,11 +16,20 @@ use Tempest\Http\Session\SessionId; use Tempest\Http\Session\SessionManager; use Tempest\Http\Status; +use Tempest\Reflection\ClassReflector; +use Tempest\Reflection\MethodReflector; +use Tempest\Router\Bindable; +use Tempest\Router\Exceptions\RouteBindingDidNotSupportRelations; use Tempest\Router\GenericRouter; +use Tempest\Router\Get; +use Tempest\Router\MatchedRoute; +use Tempest\Router\RouteBindingInitializer; use Tempest\Router\RouteConfig; use Tempest\Router\Router; +use Tempest\Router\Routing\Construction\DiscoveredRoute; use Tempest\Router\SecFetchMode; use Tempest\Router\SecFetchSite; +use Tempest\Router\WithRelations; use Tests\Tempest\Fixtures\Controllers\TestGlobalMiddleware; use Tests\Tempest\Fixtures\Controllers\TestMiddleware; use Tests\Tempest\Fixtures\Events\QueryLogger; @@ -145,6 +154,26 @@ public function route_binding_with_relations(): void $this->assertCount(1, QueryLogger::$queries); } + #[Test] + public function route_binding_with_relations_rejects_an_unsupported_resolver(): void + { + $handler = MethodReflector::fromParts(UnsupportedRelationsController::class, 'handle'); + $route = DiscoveredRoute::fromRoute(new Get('/unsupported/{binding}'), [], $handler); + + $this->container->singleton( + MatchedRoute::class, + new MatchedRoute($route, ['binding' => 'test']), + ); + + $this->expectException(RouteBindingDidNotSupportRelations::class); + + new RouteBindingInitializer()->initialize( + new ClassReflector(UnsupportedRelationsBindable::class), + tag: null, + container: $this->container, + ); + } + #[Test] public function route_binding_with_nonexistent_model_returns_404(): void { @@ -405,6 +434,22 @@ public function multiple_optional_parameters(): void } } +final readonly class UnsupportedRelationsController +{ + public function handle(#[WithRelations('author')] UnsupportedRelationsBindable $binding): Ok + { + return new Ok(); + } +} + +final readonly class UnsupportedRelationsBindable implements Bindable +{ + public static function resolve(string $input): static + { + return new self(); + } +} + final class RouteTestingSessionManager implements SessionManager { public int $createdSessions = 0;