Pop PHP
The Basics

Controllers

A controller is a plain class whose public methods answer routes. Extend Pop\Controller\AbstractController, give it a method per action, and name the pair in the route table.

A Controller and Its Actions#

The smallest controller that a controller/action route can dispatch:

PHPapp/src/Http/Controller/StatusController.php
<?php

namespace App\Http\Controller;

use Pop\Controller\AbstractController;

class StatusController extends AbstractController
{

    public function index(): void
    {
        echo 'OK';
    }

}
PHPapp/config/app.http.php
<?php
return [
    'routes' => [
        '/status' => [
            'controller' => 'App\Http\Controller\StatusController',
            'action'     => 'index'
        ]
    ]
];

Extending AbstractController is what makes the router treat the class as a dispatchable rather than as a plain callable. The router tests is_subclass_of($dispatchable, 'Pop\Dispatch\AbstractDispatcher'), and AbstractController extends AbstractDispatcher — so a controller is constructed once and then dispatched through dispatch($action, $params), which is the method that decides which of its own methods to call.

That controller receives nothing except its route parameters. Pop\Controller\AbstractController declares no constructor and no application() method of its own; a controller that extends nothing else gets new StatusController() and that's all. That's everything the router needs. Whether it is everything the rest of an application needs is a separate question, and one the scaffolded base controller answers.

Getting the Application, Request and Response#

The framework stops at the application deliberately: a controller might answer a URL or a console command, so rather than guess, it hands over neither a request nor a console. You declare the environment by adding a trait.

Declaring Pop\Dispatch\HttpTrait is what changes that. The trait supplies a constructor taking the application, a Pop\Http\Server\Request and a Pop\Http\Server\Response, the last two defaulted, so a controller declaring it has all three without writing a constructor of its own.

PHPapp/src/Http/Controller/UsersController.php
<?php

namespace App\Http\Controller;

use Pop\Controller\AbstractController;
use Pop\Dispatch\HttpTrait;

class UsersController extends AbstractController
{

    use HttpTrait;

    public function index(): void
    {
        $users = $this->application()->getService('users');

        $this->response->setBody(json_encode($users->getAll()));
        $this->response->send(200, ['Content-Type' => 'application/json']);
    }

    public function edit(string $id): void
    {
        if (!$this->request->isPost()) {
            $this->response->setBody('');
            $this->response->send(405, ['Allow' => 'POST']);
            return;
        }
    }

}

Each trait brings a different constructor and a different set of accessors:

Trait Constructor Available afterward
Pop\Dispatch\HttpTrait __construct(?Application $application = null, Request $request = new Request(new Uri()), Response $response = new Response()) application(), request(), response()
Pop\Dispatch\ConsoleTrait __construct(?Application $application = null, Console $console = new Console(120)) application(), console()

Both forms work — $this->request() and $this->request are the same object, and the properties are what the scaffolded base controller uses.

The setBody('') in edit() above is not decoration. A Pop\Http\Server\Response starts with no body object at all, and send() renders the body unconditionally, so sending a bodiless 405 or 204 without setting a body first fails with Call to a member function render() on null. Set an empty string when the status is the whole answer.

Trait detection happens on the controller/action route path — see Routing for how that form differs from a shorthand string target. To construct a controller with something other than the application, see Dispatchable Parameters.

Actions and Route Parameters#

Route parameters arrive positionally, in the order the pattern declares them, and they are always strings. An action whose route has one required parameter takes one argument; one whose parameter is optional needs a default value in the signature, because the router passes nothing at all rather than passing null.

Route Action signature
/users index()
/users/:id edit(string $id)
/reports[/:year] reports(?string $year = null)
/tags/:names* tags(array $names)
users edit <id> [--force] edit(string $id, array $options = [])

A console route's options are the exception to "positional": every [--option] the command matched arrives together in one trailing array, after the positional parameters.

The Default Action#

AbstractDispatcher::dispatch() looks for a method matching the route's action. When it does not find one, it falls back to the controller's default action rather than failing, and the default default is error:

PHP
$controller = new App\Http\Controller\UsersController();
$controller->setDefaultAction('notFound');

echo $controller->getDefaultAction();  // notFound

That fallback is why every scaffolded controller has an error() method: a route naming an action the class doesn't define dispatches error() instead, so a '*' catch-all and a typo both render the same page.

A dynamic route is the one case where the action can be absent entirely; the router substitutes index for it before dispatching.

Keep helper methods out of the action namespace. The action lookup is method_exists() and the call is made from inside the class, so any method a route names is reachable — the route table is the access boundary, not visibility.

Console Controllers#

A console controller is the same class shape with the other trait. $this->console is a Pop\Console\Console instance, and the options array is the trailing 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 index(array $options = []): void
    {
        $limit = $options['limit'] ?? 25;

        $this->console->write('Listing ' . $limit . ' users');
        $this->console->send();
    }

}

Output, colors, prompts and generated help are covered in Building Console Applications.

Maintenance Mode#

Pop\Controller\AbstractController implements Pop\Dispatch\MaintenanceInterface and uses Pop\Dispatch\MaintenanceTrait, so every controller already participates in maintenance mode. When MAINTENANCE_MODE is on and the request carries no secret, run() calls the controller's maintenance action instead of the routed one — maintenance by default, changeable with setMaintenanceAction().

PHP
class HealthController extends Pop\Controller\AbstractController
{

    use Pop\Dispatch\HttpTrait;

    protected bool $bypassMaintenance = true;

    public function index(): void
    {
        $this->response->setBody('OK');
        $this->response->send(200, ['Content-Type' => 'text/plain']);
    }

    public function maintenance(): void
    {
        $this->response->setBody('');
        $this->response->send(503);
    }

}

Setting $bypassMaintenance to true — or calling setBypassMaintenance() — exempts a controller entirely, which is what a health check or a status endpoint wants. A controller with neither a maintenance() method nor a changed maintenance action throws Pop\Dispatch\Exception when maintenance mode is on, so define one on the base controller and let it be inherited.

Controllers expose more than this page covers — MaintenanceTrait's full surface, and the dispatcher's own accessors — see the popphp README.

See Also#

  • Routing — how a route names a controller and action, and the shorthand target forms
  • Requests & Responses — what $this->request and $this->response can do
  • Views & Templates — rendering the view a controller prepares
  • Models — where the logic an action calls into belongs
  • Services — defining what getService() resolves
  • popphp README — the full controller and dispatcher API