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:
route()->get()route()->post()route()->put()route()->patch()route()->delete()route()->options()route()->any()(registers for all verbs)route()->map()(registers for multiple verbs)route()->group()(for prefix/filter/host grouping)
Examples:
route()->post('/products', ProductController::class, 'store');
route()->map(['GET', 'POST'], '/contact', ContactController::class, 'handle');
route()->any('/ping', HealthController::class);
Important:
map()throws anExceptionif 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');
- Parameters are extracted and passed to your controller action.
- Parameters are available as controller method arguments.
- Route parameters are also exposed via
request()->params('id').
Optional parameters:
route()->get('/users/:id/photos/:photo?', UserController::class, 'photo');
?makes the last parameter optional (will benullif not present).- Only the last parameter can be optional — you cannot have optional parameters in the middle.
Custom Regex & Placeholders
Use built-in placeholders or custom regex for flexible matching:
:any→.*(matches anything):seg→[^/]+(matches any segment except slash) — default for all params:num→[0-9]+(matches digits only):slug→[a-zA-Z0-9-]+(matches URL-friendly slugs):alpha→[a-zA-Z]+(matches letters only):alnum→[a-zA-Z0-9]+(matches letters and digits)
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
Exceptionwith 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']);
- Filters can be strings or arrays.
- Filters execute in the order they are defined.
- See filters docs for details on creating and using filters.
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:
- Prefixes concatenate (nested groups accumulate prefixes).
- Filters merge and deduplicate with
array_unique(). - Host is inherited if not set in nested group.
- Groups can be nested; options are merged.
- Prefix trimming handles slashes automatically.
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:
tenant1.example.com→subdomain = 'tenant1'api.v2.example.com→subdomain = 'api.v2'(multi-level subdomains work!)
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:
- All child routes inherit group bindings automatically.
- Nested groups merge bindings (child bindings override parent for same param).
- Route-level
->bind()always wins over group-level binding.
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
- Order matters: Register more specific routes before generic ones.
- Unique route names: Duplicate names throw
Exception: "Duplicate route name: {name}". - Empty paths: Empty route paths throw
Exception: "Empty route path". - Unsupported verbs:
map()throwsException: "Unsupported HTTP request method: {verb}"for invalid verbs. - Filter accumulation: Filters from groups and routes are merged and deduplicated with
array_unique(). - Group nesting: Prefixes concatenate, filters merge, host is inherited.
- Optional params: Only the last parameter can be optional using
:param?; missing params arenull. - Default pattern: Parameters without explicit patterns default to
:seg(matches[^/]+). - Model binding param names: The controller parameter name must exactly match the route parameter name for the container to inject the resolved model.
- Host matching: When a route has a host, matching checks
request()->host() . '/' . path. Routes with hosts will NOT match if the request comes from a different host. - Wildcard subdomains: The pattern
:subdomain.example.comonly matches hosts ending with.example.com. Other domains are rejected. - 404s: If no route matches, Lightpack throws a
RouteNotFoundException.