Pop PHP
The Basics

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.

PHPapp/config/app.http.php
<?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.

PHPpublic/index.php
$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:

PHP
$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.

PHPpublic/index.php
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:

PHP
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:

PHPapp/config/app.http.php
<?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
<?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' constructs App\Handler, then calls show() 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 is method_exists() only and does not test for static, 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.

PHPapp/config/app.http.php
<?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:

PHP
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:

PHPapp/config/app.http.php
<?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:

PHP
$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.

PHPapp/config/app.cli.php
<?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'
        ]
    ]
];
PHPapp
#!/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:

PHPapp/src/Console/Controller/UsersController.php
<?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
    }

}
BASH
./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.

PHPapp/config/app.http.php
<?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:

PHPapp/config/app.cli.php
<?php
return [
    'routes' => [
        '<controller> <action> [<param>]' => [
            'prefix' => 'App\Console\Controller\\'
        ]
    ]
];
BASH
./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.

PHP
$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.

PHP
$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#