Skip to content

Commit 76079bd

Browse files
committed
Merge branch 'route-decorators'
# Conflicts: # composer.lock
2 parents a6b6abe + dfe0b74 commit 76079bd

File tree

3 files changed

+248
-39
lines changed

3 files changed

+248
-39
lines changed

src/Web/Blog/articles/2025-03-30-about-route-attributes.md

Lines changed: 17 additions & 39 deletions
Original file line numberDiff line numberDiff line change
@@ -57,6 +57,10 @@ The second reason this argument fails is that in real project, route files becom
5757

5858
## Route Grouping
5959

60+
:::info
61+
Since writing this blog post, route grouping in Tempest has gotten a serious update. Read all about it [here](/blog/route-decorators)
62+
:::
63+
6064
The second-biggest argument against route attributes is the "route grouping" argument. A single route configuration file like for example in Laravel, allows you to reuse route configuration by grouping them together:
6165

6266
```php
@@ -73,56 +77,30 @@ Route::middleware([AdminMiddleware::class])
7377

7478
Laravel's approach is really useful because you can configure several routes as a single group, so that you don't have to repeat middleware configuration, prefixes, etc. for _every individual route_. With route attributes, you cannot do that — or can you?
7579

76-
Tempest's route attributes are designed so that you can make your own. Even better: you're encouraged to do so! Let's say we have a route group specifically for admins: they need to be prefixed, and they need an extra middleware for checking authentication. Here's what that looks like in Tempest. First you make a `RouteAdmin` class representing the route group:
80+
Tempest has a concept called [route decorators](/2.x/essentials/routing#route-decorators-route-groups) which are a super convenient way to model route groups and share behavior. They look like this:
7781

7882
```php
79-
use Attribute;
80-
use Tempest\Http\Method;
81-
use Tempest\Router\Route;
82-
use function Tempest\Support\path;
83-
84-
#[Attribute]
85-
final class AdminRoute implements Route
86-
{
87-
public function __construct(
88-
public string $uri,
89-
public array $middleware = [],
90-
public Method $method = Method::GET,
91-
) {
92-
$this->uri = path('/admin', $uri);
93-
$this->middleware = [AdminMiddleware::class, ...$middleware];
94-
}
95-
}
96-
```
97-
98-
And then you simply use that attribute for admin routes:
99-
100-
```php
101-
use Tempest\Http\Method;
102-
use Tempest\Http\Response;
103-
83+
#[{:hl-type:Admin:}, {:hl-type:Books:}]
10484
final class BookAdminController
10585
{
106-
#[AdminRoute('/books')]
107-
public function index(): Response { /* … */ }
86+
#[Get('/books')]
87+
public function index(): View { /* … */ }
10888

109-
#[AdminRoute('/books/{book}/show')]
110-
public function show(Book $book): Response { /* … */ }
89+
#[Get('/books/{book}/show')]
90+
public function show(Book $book): View { /* … */ }
11191

112-
#[AdminRoute('/books/new', method: Method::POST)]
113-
public function new(StoreBookRequest $request): Response { /* … */ }
92+
#[Post('/books/new')]
93+
public function new(): View { /* … */ }
11494

115-
#[AdminRoute('/books/{book}/update', method: Method::POST)]
116-
public function update(BookRequest $bookRequest, Book $book): Response { /* … */ }
95+
#[Post('/books/{book}/update')]
96+
public function update(): View { /* … */ }
11797

118-
#[AdminRoute('/books/{book}/delete', method: Method::DELETE)]
119-
public function delete(Book $book): Response { /* … */ }
98+
#[Delete('/books/{book}/delete')]
99+
public function delete(): View { /* … */ }
120100
}
121101
```
122102

123-
Of course, you could make variations like `AdminGet`, `AdminPost`, and `AdminDelete` as well, whatever fits your case. You could even make a `BookRoute` specifically for books.
124-
125-
So, the second argument against route attributes also falls short: you can in fact create route groups. I'd even say I prefer modelling route groups as classes instead of relying on configuration. That might be preference, of course, but I definitely don't feel like one approach is better than the other; which means the second argument fails as well.
103+
You can read more about its design in [this blog post](/blog/route-decorators).
126104

127105
## Route Collisions
128106

Lines changed: 230 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,230 @@
1+
---
2+
title: "Route Decorators in Tempest 2.8"
3+
description: Taking a deep dive in a new Tempest feature
4+
author: brent
5+
tag: release
6+
---
7+
8+
When I began working on Tempest, the very first features were a container and a router. I already had a clear vision on what I wanted routing to look like: to embrace attributes to keep routes and controller actions close together. Coming from Laravel, this is quite a different approach, and so I wrote about [my vision on the router's design](/blog/about-route-attributes) to make sure everyone understood.
9+
10+
> If you decide that route attributes aren't your thing then, well, Tempest won't be your thing. That's ok. I do hope that I was able to present a couple of good arguments in favor of route attributes; and that they might have challenged your opinion if you were absolutely against them.
11+
12+
One tricky part with the route attributes approach was route grouping. My proposed solution back in the day was to implent custom route attributes that grouped behavior together. For example, where Laravel would define "a route group for admin routes" like so:
13+
14+
```php
15+
Route::middleware([AdminMiddleware::class])
16+
->prefix('/admin')
17+
->group(function () {
18+
Route::get('/books', [BookAdminController::class, 'index'])
19+
Route::get('/books/{book}/show', [BookAdminController::class, 'show'])
20+
Route::post('/books/new', [BookAdminController::class, 'new'])
21+
Route::post('/books/{book}/update', [BookAdminController::class, 'update'])
22+
Route::delete('/books/{book}/delete', [BookAdminController::class, 'delete'])
23+
});
24+
```
25+
26+
Tempest's approach would look like this:
27+
28+
```php
29+
use Attribute;
30+
use Tempest\Http\Method;
31+
use Tempest\Router\Route;
32+
use function Tempest\Support\path;
33+
34+
#[Attribute]
35+
final class AdminRoute implements Route
36+
{
37+
public function __construct(
38+
public string $uri,
39+
public array $middleware = [],
40+
public Method $method = Method::GET,
41+
) {
42+
$this->uri = path('/admin', $uri);
43+
$this->middleware = [AdminMiddleware::class, ...$middleware];
44+
}
45+
}
46+
```
47+
48+
```php
49+
final class BookAdminController
50+
{
51+
#[AdminRoute('/books')]
52+
public function index(): View { /* … */ }
53+
54+
#[AdminRoute('/books/{book}/show')]
55+
public function show(Book $book): View { /* … */ }
56+
57+
#[AdminRoute('/books/new', method: Method::POST)]
58+
public function new(): View { /* … */ }
59+
60+
#[AdminRoute('/books/{book}/update', method: Method::POST)]
61+
public function update(): View { /* … */ }
62+
63+
#[AdminRoute('/books/{book}/delete', method: Method::DELETE)]
64+
public function delete(): View { /* … */ }
65+
}
66+
```
67+
68+
While I really like attribute-based routing, grouping route behavior does feel… suboptimal because of attributes. A couple of nitpicks:
69+
70+
- Tempest's default route attributes are represented by HTTP verbs: `#[Get]`, `#[Post]`, etc. Making admin variants for each verb might be tedious, so in my previous example I decided to use one `#[AdminRoute]`, where the verb would be specified manually. There's nothing stopping me from adding `#[AdminGet]`, `#[AdminPost]`, etc; but it doesn't feel super clean.
71+
- When you prefer to namespace admin-specific route attributes like `#[Admin\Get]`, and `#[Admin\Post]`, you end up with naming collisions between normal- and admin versions. I've always found those types of ambiguities to increase cognitive load while coding.
72+
- This approach doesn't really scale: say there are two types of route groups that require a specific middleware (`AuthMiddleware`, for example), then you end up making two or more route attributes, duplicating that logic of adding `AuthMiddleware` to both.
73+
- Say you want nested route groups: one for admin routes and then one for book routes (with a `/admin/books` prefix), you end up with yet another variant called `#[AdminBookRoute]` attribute, not ideal.
74+
75+
So… what's the solution? I first looked at Symfony, which also uses attributes for routing:
76+
77+
```php
78+
#[Route('/admin/books', name: 'admin_books_')]
79+
class BookAdminController extends AbstractController
80+
{
81+
#[Route('/', name: 'index')]
82+
public function index(): Response { /* … */ }
83+
84+
#[Route('/{book}/show')]
85+
public function show(Book $book): Response { /* … */ }
86+
87+
#[Route('/new', methods: ['POST'])]
88+
public function new(): Response { /* … */ }
89+
90+
#[Route('/{book}/update', methods: ['POST'])]
91+
public function update(): Response { /* … */ }
92+
93+
#[Route('/{book}/delete', methods: ['DELETE'])]
94+
public function delete(): Response { /* … */ }
95+
}
96+
```
97+
98+
I think Symfony's approach gets us halfway there: it has the benefit of being able to define "shared route behavior" on the controller level, but not across controllers. You could create abstract controllers like `AdminController` and `AdminBookController`, which doesn't scale horizontally when you want to combine multiple route groups, because PHP doesn't have multi-inheritance. On top of that, I also like Tempest's design of using HTTP verbs to model route attributes like `#[Get]` and `#[Post]`, which is missing with Symfony. All of that to say, I like Symfony's approach, but I feel like there's room for improvement.
99+
100+
With the scene now being set, let's see the design we ended up with in Tempest.
101+
102+
## A Tempesty solution
103+
104+
A week ago, my production server suddenly died. After some debugging, I realized the problem had to do with the recent refactor of [my blog](https://stitcher.io) to Tempest. The RSS and meta-image routes apparently started a session, which eventually led to the server being overflooded with hundreds of RSS reader- and social media requests per minute, each of them starting a new session. The solution was to remove all session-related middleware (CSRF protection, and "back URL" support) from these routes. While trying to come up with a proper solution, I had a realization: instead of making a "stateless route" class, why not add an attribute that worked _alongside_ the existing route attributes? That's what led to a new `#[Stateless]` attribute:
105+
106+
```php
107+
#[Stateless, {:hl-type:Get:}('/rss')]
108+
public function rss(): Response {}
109+
```
110+
111+
This felt like a really nice solution: I didn't have to make my own route attributes anymore, but could instead "decorate" them with additional functionality. The first iteration of the `#[Stateless]` attribute was rather hard-coded in Tempest's router (I was on the clock, trying to revive my server), it looked something like this:
112+
113+
```php
114+
// Skip middleware that sets cookies or session values when the route is stateless
115+
if (
116+
$matchedRoute->route->handler->hasAttribute(Stateless::class)
117+
&& in_array(
118+
needle: $middlewareClass->getName(),
119+
haystack: [
120+
VerifyCsrfMiddleware::class,
121+
SetCurrentUrlMiddleware::class,
122+
SetCookieMiddleware::class,
123+
],
124+
strict: true,
125+
)
126+
) {
127+
return $callable($request);
128+
}
129+
```
130+
131+
I knew, however, that it would be trivial to make this into a reusable pattern. A couple of days later and that's exactly what I did: route decorators are Tempest's new way of modeling grouped route behavior, and I absolutely love them. Here's a quick overview.
132+
133+
First, route decorators work _alongside_ route attributes, not as a _replacement_. This means that they can be combined in any way you'd like, and they should all work together seeminglessly:
134+
135+
```php
136+
final class BookAdminController
137+
{
138+
#[{:hl-type:Admin:}, {:hl-type:Books:}, {:hl-type:Get:}('/{book}/show')]
139+
public function show(Book $book): View { /* … */ }
140+
141+
// …
142+
}
143+
```
144+
145+
Furthermore, route decorators can also be defined on the controller level, which means they'll be applied to all its actions:
146+
147+
```php
148+
#[{:hl-type:Admin:}, {:hl-type:Books:}]
149+
final class BookAdminController
150+
{
151+
#[Get('/books')]
152+
public function index(): View { /* … */ }
153+
154+
#[Get('/books/{book}/show')]
155+
public function show(Book $book): View { /* … */ }
156+
157+
#[Post('/books/new')]
158+
public function new(): View { /* … */ }
159+
160+
#[Post('/books/{book}/update')]
161+
public function update(): View { /* … */ }
162+
163+
#[Delete('/books/{book}/delete')]
164+
public function delete(): View { /* … */ }
165+
}
166+
```
167+
168+
Finally, you're encouraged to make your custom route attributes as well (you might have already guessed that because of `#[Admin]` and `#[Books]`). Here's what both of these attributes would look like:
169+
170+
```php
171+
use Attribute;
172+
use Tempest\Router\RouteDecorator;
173+
174+
#[Attribute(Attribute::TARGET_METHOD | Attribute::TARGET_CLASS)]
175+
final readonly class Admin implements RouteDecorator
176+
{
177+
public function decorate(Route $route): Route
178+
{
179+
$route->uri = path('/admin', $route->uri)->toString();
180+
$route->middleare[] = AdminMiddleware::class;
181+
182+
return $route;
183+
}
184+
}
185+
```
186+
187+
```php
188+
use Attribute;
189+
use Tempest\Router\RouteDecorator;
190+
191+
#[Attribute(Attribute::TARGET_METHOD | Attribute::TARGET_CLASS)]
192+
final readonly class Books implements RouteDecorator
193+
{
194+
public function decorate(Route $route): Route
195+
{
196+
$route->uri = path('/books', $route->uri)->toString();
197+
198+
return $route;
199+
}
200+
}
201+
```
202+
203+
You can probably guess what a route decorator's job is: it is passed the current route, it can do some changes to it, and then return it. You can add and combine as many route decorators as you'd like, and Tempest's router will stitch them all together. Under the hood, that looks like this:
204+
205+
```php
206+
// Get the route attribute
207+
$route = $method->getAttributes(Route::class);
208+
209+
// Get all decorators from the method and its controller class
210+
$decorators = [
211+
...$method->getDeclaringClass()->getAttributes(RouteDecorator::class),
212+
...$method->getAttributes(RouteDecorator::class),
213+
];
214+
215+
// Loop over each decorator and apply it one by one
216+
foreach ($decorators as $decorator) {
217+
$route = $decorator->decorate($route);
218+
}
219+
```
220+
221+
As an added benefit: all of this route decorating is done during [Tempest's discovery phase](/2.x/internals/discovery), which means the decorated route will be cached, and decorators themselves won't be run in production.
222+
223+
On top of adding the {b`Tempest\Router\RouteDecorator`} interface, I've also added a couple of built-in route decorators that come with the framework:
224+
225+
- {b`Tempest\Router\Prefix`}: which adds a prefix to all decorated routes.
226+
- {b`Tempest\Router\WithMiddleware`}: which adds one or more middleware classes to all decorated routes.
227+
- {b`Tempest\Router\WithoutMiddleware`}: which explicitely removes one or more middleware classes from the default middleware stack to all decorated routes.
228+
- {b`Tempest\Router\Stateless`}: which will remove all session and cookie related middleware from the decorated routes.
229+
230+
I really like the solution we ended up with. I think it combines the best of both worlds. Maybe you have some thoughts about it as well? [Join the Tempest Discord](/discord) to let us know! You can also read all the details of route decorators [in the docs](/2.x/essentials/routing#route-decorators-route-groups).

src/Web/assets/highlighting.css

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -64,6 +64,7 @@
6464
.hl-property > .hl-keyword {
6565
color: var(--code-property);
6666
}
67+
.hl-type > span,
6768
.hl-type {
6869
color: var(--code-type);
6970
}

0 commit comments

Comments
 (0)