When I started redesigning routing for SqueHub v2, I had one question in mind:
When a developer opens a routes file six months later, how quickly can they understand what the application is doing?
Routing is one of the first parts of a framework developers interact with.
It is also one of those APIs that can become complicated very quickly.
For SqueHub v2, I wanted routing to remain expressive without hiding the most important part of a route:
the path.
That led to what I call a path-first routing API.
The basic idea
A simple SqueHub route looks like this:
use App\Plugins\Route;
`Route::path('/hello')
->get(static fn (): string => 'Hello, SqueHub!');`
Instead of starting with the HTTP method, the route starts with the resource being addressed:
`Route::path('/hello')
`
Then the developer describes what should happen with that path:
->get(...)
The idea is simple:
Path first. Behavior second.
Why not start with Route::get()?
A lot of PHP developers are familiar with APIs that look like this:
Route::get('/users', ...);
There is nothing inherently wrong with that design.
It is compact and familiar.
But while working on SqueHub v2, I wanted the route declaration to behave more like a small configuration object.
Starting from the path gives us a natural place to compose everything related to that route.
For example:
Route::path('/users/{id}')
->get([UserController::class, 'show'])
->named('users.show')
->through('auth');
When I read this, I can mentally interpret it as:
For /users/{id}, accept GET requests, dispatch them to this controller, name the route users.show, and pass the request through authentication middleware.
That is the kind of readability I wanted from the framework.
Routes should read naturally
One of the design principles I keep returning to while building SqueHub is that framework APIs should not require developers to constantly translate syntax in their heads.
The code should explain itself.
Consider:
Route::path('/dashboard')
->get([DashboardController::class, 'index'])
->through('auth')
->named('dashboard');
There are no unusual abbreviations here.
There is also very little framework knowledge required to understand what the route does.
That becomes more valuable as an application grows.
Route groups follow the same philosophy
The same design carries over to route groups.
For example:
Route::group()
->prefix('/admin')
->through('auth')
->routes(function (): void {
Route::path('/users')
->get([UserController::class, 'index']);
Route::path('/settings')
->get([SettingsController::class, 'index']);
});
The group describes its context:
->prefix('/admin')
->through('auth')
Then the routes live inside that context.
Again, the goal isn't to make the syntax clever.
It is to make the structure obvious.
Middleware should remain visible
Middleware is another place where routing APIs can become difficult to follow.
For SqueHub v2, route middleware can remain directly attached to the route:
Route::path('/account')
->get([AccountController::class, 'show'])
->through('auth');
Or middleware can be inherited from a group.
Route::group()
->through('auth')
->routes(function (): void {
// Protected routes
});
This allows the developer to quickly understand which parts of the application require authentication or other request processing.
Keeping application code separate from framework internals
The routing API is also part of a larger design decision in SqueHub v2.
Application code primarily lives inside:
Project/
while framework internals live inside:
App/
A typical project can contain areas such as:
Project/
├── Controllers/
├── Middleware/
├── Models/
├── Routes/
├── Views/
├── Packages/
└── Kits/
Routes typically live inside:
Project/Routes/
This separation makes it clearer which code belongs to the application and which code belongs to the framework.
Why App\Plugins\Route?
Another decision in SqueHub v2 was introducing a stable developer-facing namespace:
App\Plugins
So application code imports routing like this:
use App\Plugins\Route;
instead of depending directly on internal routing implementation classes.
The idea is that framework internals should be free to evolve without forcing every application to know how those internals are structured.
The App\Plugins layer becomes the public surface developers build against.
Route model binding
SqueHub also takes an explicit approach to route model binding.
For example:
Route::path('/users/{user}')
->get([UserController::class, 'show'])
->bind('user', User::class);
A custom key can also be defined when needed.
The important design choice here is that model binding is explicit.
SqueHub does not automatically perform database lookups merely because a controller parameter happens to have a model type.
I prefer the route to tell the developer when a database-backed binding is going to happen.
That makes the behavior easier to reason about and avoids hidden work.
Framework design is mostly about trade-offs
There is rarely one universally correct API.
Starting routes with:
Route::get(...)
can be perfectly reasonable.
Starting with:
Route::path(...)
also has trade-offs.
The question I try to ask while designing SqueHub is not:
How do other frameworks do this?
It is:
What will make this API understandable, predictable, and maintainable for a SqueHub developer?
Sometimes the answer will look familiar.
Sometimes it will lead to something different.
The important part is that the design decision should have a reason behind it.
Simplicity does not mean fewer capabilities
One thing I have learned while building SqueHub v2 is that a simple API and a capable framework are not opposites.
The framework can provide routing, middleware, validation, authentication, ORM functionality, queues, scheduling, caching, Redis, storage, events, HTTP clients, cryptography, packages, and other capabilities.
The challenge is preventing all of that power from leaking into every line of application code.
That is the balance I am trying to find with SqueHub:
Simple to use. Powerful underneath.
Try the routing API
SqueHub v2 is open source and requires PHP 8.2+.
You can create a project with Composer:
composer create-project squehub/squehub my-app "^2.0"
or
composer create-project squehub/squehub my-app
Then:
cd my-app
php squehub key:generate
php squehub doctor
php squehub start
GitHub:
https://github.com/squehub/squehub
Documentation:
https://www.squehub.com/docs/v2.x
Website:
https://www.squehub.com/
I’d like PHP developers’ opinions
I am particularly interested in feedback on the routing design.
Do you prefer:
Route::get('/users', ...);
or a composable path-first style such as:
Route::path('/users')
->get(...);
And more importantly:
What makes a routing API feel good to use in a large PHP application?
I’d love to hear how other PHP developers think about that trade-off.
Top comments (0)