DEV Community

Cover image for Designing SqueHub v2: Why I Chose a Path-First Routing API for PHP
Valentine Kalu for SqueHub

Posted on

Designing SqueHub v2: Why I Chose a Path-First Routing API for PHP

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!');`

Enter fullscreen mode Exit fullscreen mode

Instead of starting with the HTTP method, the route starts with the resource being addressed:

`Route::path('/hello')
`
Enter fullscreen mode Exit fullscreen mode

Then the developer describes what should happen with that path:

->get(...)

Enter fullscreen mode Exit fullscreen mode

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', ...);

Enter fullscreen mode Exit fullscreen mode

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');
Enter fullscreen mode Exit fullscreen mode

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');
Enter fullscreen mode Exit fullscreen mode

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']);
    });
Enter fullscreen mode Exit fullscreen mode

The group describes its context:

->prefix('/admin')
->through('auth')
Enter fullscreen mode Exit fullscreen mode

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');
Enter fullscreen mode Exit fullscreen mode

Or middleware can be inherited from a group.

Route::group()
    ->through('auth')
    ->routes(function (): void {
        // Protected routes
    });
Enter fullscreen mode Exit fullscreen mode

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;

Enter fullscreen mode Exit fullscreen mode

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);
Enter fullscreen mode Exit fullscreen mode

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(...)

Enter fullscreen mode Exit fullscreen mode

can be perfectly reasonable.
Starting with:

Route::path(...)

Enter fullscreen mode Exit fullscreen mode

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

Enter fullscreen mode Exit fullscreen mode

Then:

cd my-app
php squehub key:generate
php squehub doctor
php squehub start
Enter fullscreen mode Exit fullscreen mode

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', ...);

Enter fullscreen mode Exit fullscreen mode

or a composable path-first style such as:

Route::path('/users')
    ->get(...);
Enter fullscreen mode Exit fullscreen mode

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)