Routing

Lightpack’s routing system lets you map incoming HTTP request URLs to appropriate controllers and actions with clarity and flexibility. This guide covers all routing features available to app developers—so you can build delightful, robust APIs and web apps.


Quick Start

// Basic GET route (defaults to 'index' action)
route()->get('/products', ProductController::class);

// Specify action
route()->get('/products', ProductController::class, 'list');

// Route with parameter
route()->get('/products/:id', ProductController::class, 'show');

Note: All route methods (get(), post(), put(), etc.) default to the 'index' action method if not specified.


Route Files

Define routes in the routes folder:

routes/
  ├── web.php   // for web routes
  └── api.php   // for API routes

Route Methods

Lightpack supports all HTTP verbs and flexible grouping:

Examples:

route()->post('/products', ProductController::class, 'store');
route()->map(['GET', 'POST'], '/contact', ContactController::class, 'handle');
route()->any('/ping', HealthController::class);

Important: map() throws an Exception if you provide an unsupported HTTP verb (only GET, POST, PUT, PATCH, DELETE, OPTIONS are supported).


Route Parameters

Define dynamic segments with :param syntax:

route()->get('/users/:id', UserController::class, 'show');

Optional parameters:

route()->get('/users/:id/photos/:photo?', UserController::class, 'photo');

Custom Regex & Placeholders

Use built-in placeholders or custom regex for flexible matching:

Using placeholders:

route()->get('/posts/:slug', PostController::class)->pattern(['slug' => ':slug']);

Using custom regex:

route()->get('/users/:id', UserController::class)->pattern(['id' => '[0-9]{4}']);

Note: If you don't specify a pattern, parameters default to :seg (matches any non-slash characters).


Route Naming & URL Generation

Name routes for easy URL generation:

route()->get('/products/:id', ProductController::class)->name('product.show');

Generate URLs using the url() method:

route()->url('product.show', ['id' => 42]); // /products/42

Important: The second parameter must be an associative array with keys matching the route parameter names.

Important: Route names must be unique. Registering a duplicate name throws an Exception with message: "Duplicate route name: {name}".

Route with optional parameters

// Returns: /blog/tech/php-tips
route()->url('blog.post', [
    'category' => 'tech',
    'slug' => 'php-tips'
]); 

Route with query parameters:

// Returns: /search?q=php&page=1
route()->url('search', [
    'q' => 'php',
    'page' => 1
]); 

Route Filters

Filters are request interceptors that allow you to execute logic before or after a route is processed — perfect for authentication, validation, logging, or any cross-cutting concerns.

Attach filters to routes or groups:

route()->get('/admin', AdminController::class)->filter('auth');
route()->post('/users', UserController::class)->filter(['auth', 'csrf']);

Route Groups

Group routes by prefix, filters, or host:

// Prefix group
route()->group(['prefix' => '/api/v1'], function() {
    route()->get('/users', UserController::class);
    // Results in: /api/v1/users
});

// Filter group
route()->group(['filter' => ['auth']], function() {
    route()->post('/posts', PostController::class);
    // All routes inherit 'auth' filter
});

// Host-based group
route()->group(['host' => 'admin.example.com'], function() {
    route()->get('/dashboard', AdminController::class);
    // Only matches on admin.example.com
});

Group behavior:


Multi-Verb Routes

Register a route for multiple HTTP verbs with map() or all verbs with any():

// GET and POST
route()->map(['GET', 'POST'], '/feedback', FeedbackController::class, 'submit');

// All verbs (GET, POST, PUT, PATCH, DELETE, OPTIONS)
route()->any('/status', StatusController::class);

Note: any() registers the route for all supported HTTP verbs: GET, POST, PUT, PATCH, DELETE, OPTIONS.


Signed Routes

Generate a signed URL for a given route for secure access:

// Generate signed URL (expires in 1 hour default)
$signedUrl = route()->sign('download.file', ['id' => 123]);

// Generate signed URL with custom expiration
$signedUrl = route()->sign('download.file', ['id' => 123], 7200); // 2 hours

Verify signed URL using url() utility helper:

if (url()->verify($signedUrl)) {
    // URL is valid and not expired
}

Verify with ignored parameters:

url()->verify($signedUrl, ['utm_source', 'utm_medium']);

Read more about url() utility helper here.

Note: To easily verify current request URL, see Request docs.


Subdomain / Host-Based Routing

You can restrict routes to specific hosts or subdomains:

Group-level:

route()->group(['host' => 'api.example.com'], function() {
    route()->get('/users', UserController::class);
});

Individual route:

route()->get('/admin', AdminController::class)->host('admin.example.com');

Wildcard subdomains:

route()->group(['host' => ':subdomain.example.com'], function() {
    route()->get('/dashboard', DashboardController::class);
});
// Matches: tenant1.example.com, tenant2.example.com, etc.
// Subdomain available as parameter in controller method

The wildcard :subdomain extracts everything before .example.com:

Access the subdomain parameter in your controller:

public function dashboard()
{
    $subdomain = request()->params('subdomain');
    // or as method parameter
}

public function dashboard($subdomain)
{
    // $subdomain is automatically injected
}

Route Model Binding

Automatically resolve route parameters into model instances before they reach your controller. Lightpack supports both explicit binding (by ID) and custom binding (via callback).

In this example, the :note parameter in the route will be automatically resolved to a Note model instance:

route()->get('/notes/:note', NoteController::class, 'show')
    ->bind('note', Note::class);
class NoteController
{
    public function show(Note $note)
    {
        return $note->title;
    }
}

The Note model will be automatically instantiated with the ID from the route parameter. Behind the scenes, Lightpack calls new Note($note) where $note is the ID of the note record.

SO this is identical to new Note(5) when the URL is /notes/5. If no record is found, the model throws a RecordNotFoundException (404).

Use semantic names (:note, :comment, :post) instead of generic names (:id, :slug). It makes controller signatures natural and readable.

Custom Binding (Callback Resolution)

The default resolution mechanism is to instantiate the model with the route parameter value as record ID. But you can also provide a custom callback for more control.

Provide a callback for non-ID lookups or ownership checks:

route()->get('/notes/:slug', NoteController::class, 'show')
    ->bind('note', Note::class, fn($slug) => Note::query()->where('slug', $slug)->one());

The callback receives the raw route parameter value and must return the resolved model instance. If the callback returns null the route will result in a 404.

Multiple Bindings

Chain bind() calls for routes with multiple parameters:

route()->get('/posts/:post/comments/:comment', CommentController::class, 'show')
    ->bind('post', Post::class)
    ->bind('comment', Comment::class);

Each parameter is resolved independently. It doesn't check if the comment belongs to the post, for example.

Group-Level Bindings

Apply bindings to all routes in a group — no repetition. Use the short form (just the class name) for ID-based resolution, or the full array form for custom resolvers:

// Short form (recommended for ID resolution)
route()->group([
    'bind' => [
        'note' => Note::class
    ]
], function () {
    route()->get('/notes/:note', NoteController::class, 'show');
    route()->get('/notes/:note/edit', NoteController::class, 'edit');
    route()->delete('/notes/:note', NoteController::class, 'destroy');
});

// Full form (for custom resolvers)
route()->group([
    'bind' => [
        'slug' => [
            'model' => Note::class,
            'resolver' => fn($slug) => Note::query()->where('slug', $slug)->one()
        ]
    ]
], function () {
    route()->get('/notes/:slug', NoteController::class, 'show');
});

// Multiple bindings in a group

route()->group([
    'bind' => [
        'note' => Note::class,
        'comment' => Comment::class
    ]
], function () {
    route()->get('/notes/:note/comments/:comment', CommentController::class, 'show');
    route()->put('/notes/:note/comments/:comment', CommentController::class, 'update');
    route()->delete('/notes/:note/comments/:comment', CommentController::class, 'destroy');
});

Group-level behavior:

Nested groups with merge + override:

You can nest groups and the bindings will merge:

route()->group(['bind' => ['note' => Note::class]], function () {
    route()->group(['bind' => ['comment' => Comment::class]], function () {
        // Both 'note' and 'comment' bindings are active here
        route()->get('/notes/:note/comments/:comment', CommentController::class, 'show');
    });
});

Best Practices & Gotchas