Routing
One router serves both sides of a Pop application: HTTP requests matched against a URL path, and console commands matched against their arguments. Declare routes in a config array, fluently on the application object, or both — they all land in the same router.
Defining Routes in Config#
A config file that returns a routes array is the usual starting point. Each key is a route pattern and
each value is the target the router dispatches to.
<?php
return [
'routes' => [
'[/]' => [
'controller' => 'App\Http\Controller\IndexController',
'action' => 'index'
],
'/users[/]' => [
'controller' => 'App\Http\Controller\UsersController',
'action' => 'index'
],
'/users/:id' => [
'controller' => 'App\Http\Controller\UsersController',
'action' => 'edit',
'name' => 'users.edit'
],
'*' => [
'controller' => 'App\Http\Controller\IndexController',
'action' => 'error'
]
]
];
The front controller includes that file and hands it to the application, which parses the routes key
during bootstrap and registers every entry with the router.
$app = new Pop\Application(include __DIR__ . '/../app/config/app.http.php');
$app->run();
A request for /users/1001 now dispatches App\Http\Controller\UsersController::edit('1001'). The [/] in
/users[/] makes the trailing slash optional, so /users and /users/ both match. The * key is the
catch-all: it's dispatched when nothing else matches, which is where you handle a 404. The optional name
key gives a route a handle that Pop\Router\Route::url() can turn back into a URL.
Declaration order does not decide which route wins. The most specific match wins regardless of where it
appears in the file: a fully literal path beats one with required parameters, which beats one with optional
parameters, which beats one with a collection parameter. Order is only a tiebreaker between routes of equal
specificity, so /users/new and /users/:id can be listed in either order and /users/new still wins.
Route keys nest, which keeps a long table readable. A nested key is concatenated onto its parent:
$app = new Pop\Application();
$app->addRoutes([
'/system' => [
'/add' => 'App\Handler->add',
'/edit' => 'App\Handler->edit',
],
]);
That registers /system/add and /system/edit.
Defining Routes Fluently#
The application object also exposes one method per HTTP verb, each returning the application so calls chain.
use Pop\Application;
$app = new Application();
$app->get('/users', ['controller' => 'App\Http\Controller\UsersController', 'action' => 'index'])
->post('/users', ['controller' => 'App\Http\Controller\UsersController', 'action' => 'create'])
->get('/users/:id', ['controller' => 'App\Http\Controller\UsersController', 'action' => 'edit']);
$app->run();
get, head, post, put, delete, trace, options, connect and patch are all available this way,
and each is equivalent to an array config entry carrying the matching method key. A plain
$app->addRoute('/path', $target) registers a route with no method constraint at all.
Reach for the fluent form for short tables and conditional routes; reach for the config array when you want the table declarative and greppable, which is most of the time. The verb methods are HTTP-only.
The fluent equivalent of the name config key is name(), which applies to the most recently added route.
Pop\Router\Route is the static accessor your views and controllers use afterward:
use Pop\Router\Route;
use Pop\Router\Router;
use Pop\Router\Match\Http;
$router = new Router(null, new Http());
$router->addRoute('/user/:id', 'App\Handler->show')->name('user');
Route::setRouter($router);
echo Route::url('user', ['id' => 1001]); // /user/1001
echo Route::url('user', ['id' => 1001], true); // http://www.example.com/user/1001
Passing true as the third argument returns a fully qualified URL built from the current host.
Route Parameters#
A :name segment is required, a [/:name] group is optional, and a * suffix collects the remaining
segments into a single array parameter.
| Pattern | Matches | Handler signature |
|---|---|---|
/users/:id |
/users/1001, but not /users |
show($id) |
/reports[/:year] |
both /reports and /reports/2026 |
reports($year = null) |
/tags/:names* |
/tags/php/web/cli, passing one array |
tags(array $names) |
/search[/:terms*] |
/search, and /search/a/b |
search(array $terms = []) |
/admin/* |
anything under /admin |
admin() |
Parameters are passed positionally into the target, in the order they appear in the pattern. An optional parameter needs a default value in the signature, and a collection parameter receives an array even when it captured a single segment.
Write the slash inside the brackets — [/:year] — so the optional group covers the separator too. And to
serve both /users and /users/1001 from one route, make the parameter optional and give it a default:
/users[/:id] with show($id = null).
Controller and Action Targets#
A route's target can be a closure, a controller/action pair, or a shorthand string. The three are not
interchangeable.
The smallest target is a callable. Route parameters are passed straight into it, so a whole endpoint can live in the route table:
<?php
return [
'routes' => [
'/hello' => [
'controller' => function() {
echo 'Hello World';
}
],
'/hello/:name' => [
'controller' => function($name) {
echo 'Hello ' . $name;
}
]
]
];
For anything with structure you want a controller class, named by the controller/action pair. Declare
Pop\Dispatch\HttpTrait on it — or Pop\Dispatch\ConsoleTrait for a console application — and the router
constructs it with the application, giving you application(), request() and response():
<?php
namespace App\Http\Controller;
use Pop\Controller\AbstractController;
use Pop\Dispatch\HttpTrait;
class UsersController extends AbstractController
{
use HttpTrait;
public function index()
{
$users = $this->application()->getService('users');
}
public function edit($id)
{
// $this->request and $this->response are available here too
}
}
Declaring the trait once on a shared base controller wires everything extending it — see Controllers.
The shorthand string form puts the whole target in the route's value instead, resolved through
Pop\Utils\CallableObject:
'App\Handler->show'constructsApp\Handler, then callsshow()on it. Route params go to the method.'App\Handler::show'calls the static method directly. Route params go to the method. The registration check ismethod_exists()only and does not test forstatic, so naming an instance method here registers cleanly and fails later at dispatch.'App\Handler'constructs the class and passes the route params to the constructor. No method is called.
Shorthand works anywhere a route accepts a target — array configs, the fluent verb methods, wildcard and
default routes, and method groups — for both HTTP and CLI routes. A shorthand string naming a class or
method that does not exist throws a Pop\Utils\Exception at registration time rather than failing later at
dispatch.
Use shorthand for plain handlers and invokables, and the controller/action keys for anything extending
AbstractController — those keys are what enable trait detection, default actions and maintenance-mode
handling. Controller structure and dispatch precedence are covered in Controllers.
HTTP Methods#
An HTTP route matches any method unless you constrain it. The method key takes a single method, a
comma-separated string, or an array.
<?php
return [
'routes' => [
'/users' => [
'controller' => 'App\Http\Controller\UsersController',
'action' => 'index',
'method' => 'get'
],
'/users/:id' => [
'controller' => 'App\Http\Controller\UsersController',
'action' => 'update',
'method' => 'put,patch'
]
]
];
PHP array keys are unique, so the same path cannot appear twice in one routes array. To serve
GET /users and POST /users from different actions, register the pair fluently:
use Pop\Application;
$app = new Application();
$app->get('/users', ['controller' => 'App\Http\Controller\UsersController', 'action' => 'index'])
->post('/users', ['controller' => 'App\Http\Controller\UsersController', 'action' => 'create']);
Or group the routes by method in config, in the Popcorn style — the shape a popphp/popcorn routes config
used, kept in v7 for applications migrating off it. A top-level key that is a bare comma-separated list of
methods — never a real path, which always starts with /, is *, or contains :controller — applies that
method list to every route nested beneath it, at any depth:
<?php
return [
'routes' => [
'get,options' => [
'/users' => [
'[/]' => ['controller' => 'App\Http\Controller\UsersController', 'action' => 'index'],
'/count' => ['controller' => 'App\Http\Controller\UsersController', 'action' => 'count'],
],
],
'post,options' => [
'/users' => [
'/create' => ['controller' => 'App\Http\Controller\UsersController', 'action' => 'create'],
],
],
]
];
This expands internally into per-route method keys, and the two forms mix freely in one config. If a
nested route sets its own method key, the group's list wins.
The distinction between a missing route and a wrong method is handled for you. If no registered path matches
the request URI, the response is a 404. If a path matches but none of its method constraints accept the
request's method, and no wildcard or dynamic route can take it, the response is a 405 with an Allow header
listing the methods that path does accept.
Custom methods — WebDAV verbs, for instance — have to be whitelisted before they can be used as a verb call:
$app = new Pop\Application();
$app->addCustomMethod('propfind');
$app->propfind('/dav', ['controller' => 'App\Http\Controller\DavController', 'action' => 'index']);
CLI Routes#
The router auto-detects its environment, so a console application uses the same routes key with a command
signature in place of a URL path.
<?php
return [
'routes' => [
'help' => [
'controller' => 'App\Console\Controller\ConsoleController',
'action' => 'help'
],
'users list [--limit=]' => [
'controller' => 'App\Console\Controller\UsersController',
'action' => 'index'
],
'users edit <id> [-f|--force]' => [
'controller' => 'App\Console\Controller\UsersController',
'action' => 'edit'
],
'*' => [
'controller' => 'App\Console\Controller\ConsoleController',
'action' => 'error'
]
]
];
#!/usr/bin/env php
<?php
$app = new Pop\Application(include __DIR__ . '/app/config/app.cli.php');
$app->run();
Bare words are literal commands, and everything else mirrors the HTTP side with angle brackets in place of colons.
| Form | Example command | Arrives as |
|---|---|---|
users list |
users list |
nothing — two literal command words |
<id> |
users edit 1001 |
'1001', positionally |
[<id>] |
users edit |
null, positionally |
[--force] |
users list --force |
$options['force'] === true |
[-f] |
users list -f |
$options['f'] === true |
[-f|--force] |
users list -f or users list --force |
$options['force'] === true |
[--limit=] |
users list --limit=25 |
$options['limit'] === '25' |
[-l|--limit=] |
users list --limit=25 or users list -l25 |
$options['limit'] === '25' |
[--id=*] |
users list --id=1 --id=2 |
$options['id'] === ['1', '2'] |
An option declared with both spellings always arrives under the long name; a short-only option arrives under
its letter. Specificity beats declaration order here too, so users new wins over users <id> for the
command users new.
Parameters are passed positionally and options arrive together in one trailing array parameter:
<?php
namespace App\Console\Controller;
use Pop\Controller\AbstractController;
use Pop\Dispatch\ConsoleTrait;
class UsersController extends AbstractController
{
use ConsoleTrait;
public function edit($id, array $options = [])
{
$force = !empty($options['force']);
// Edit user $id, skipping confirmation when --force was passed
}
}
./app users edit 1001 --force
Always define a '*' route in a CLI table — without one, a mistyped command produces nothing at all.
Wiring a console application up with input parsing, colored output and help text is covered in
Building Console Applications.
Dynamic Routing#
:controller and :action are reserved parameter names. When a route uses them, the matched URL segments
are turned into a class name and a method name, with a prefix supplying the namespace.
<?php
return [
'routes' => [
'/:controller/:action[/:param]' => [
'prefix' => 'App\Http\Controller\\'
]
]
];
A request for /users/edit/1001 resolves to App\Http\Controller\UsersController::edit('1001'). The CLI form
uses angle brackets:
<?php
return [
'routes' => [
'<controller> <action> [<param>]' => [
'prefix' => 'App\Console\Controller\\'
]
]
];
./app users edit 1001
That dispatches App\Console\Controller\UsersController::edit('1001').
Keep the dynamic segments first: the controller always comes from the first segment of the request and the action from the second, so a literal in front of them becomes dead text.
Dynamic routing earns its place in admin scaffolding and internal tools where the controller set is trusted and churning. Declaring the routes gives you per-route method constraints, route names and per-route config, so for anything public-facing, declare them.
Forcing a Route#
A queued job needs to run a console command, but there's no console invocation to route from. Pass the
route you want as the second argument to run() and the router matches that instead of the real request —
$_SERVER['argv'] for CLI, $_SERVER['REQUEST_URI'] for HTTP. The first argument, false, keeps the
application from exiting after dispatch, so the worker survives to handle the next job.
$app = new Pop\Application(include __DIR__ . '/app/config/app.cli.php');
$app->run(false, 'send:email --quiet 1001'); // string form
$app->run(false, ['send:email', '-q', 'John Smith']); // array form
A forced route carries its own parameters and options exactly as a real request does — the command is parsed
for its <param>s and [--option]s, and a forced HTTP path is parsed for its :params.
$app = new Pop\Application(include __DIR__ . '/app/config/app.http.php');
$app->run(false, '/user/42');
The string form is split on whitespace and has no quoting support, so a value containing spaces has to go
through the array form, which takes pre-split segments exactly like real argv. Router::route() takes the
same argument, if you are driving the router without an application around it.
A forced HTTP route is matched as-is, so under a subdirectory deployment pass the path relative to that
subdirectory — /user/42, not /subdir/user/42. Each call re-parses fresh and clears the controller, action
and params from the previous one, so one long-lived instance can dispatch many routes in sequence.
The router exposes more than this page covers — the route match objects behind getRouteMatch(),
dispatchable params, and the per-route config keys your own event listeners can read — see the popphp README.
See Also#
- Applications & Bootstrap — where the
routesconfig is read and the router registered - Controllers — what a dispatched controller gets, and how actions resolve
- Middleware — the per-route
middlewarekey, and the pipeline it feeds - Building Console Applications — console input, output and help built on CLI routes
- popphp README — the full router API surface