Skip to content
popphpPublic

About

Pop PHP Framework Core Components

Resources

Stars

68 stars

Watchers

12 watching

Forks

Repository files navigation

popphp

Build Status Coverage Status

Join the chat at https://discord.gg/TZjgT74U7E

Overview

popphp is the main set of core components for the Pop PHP Framework. It provides the main Application object that can be configured to manage and interface with the underlying core components:

  • Router
  • Controller
  • Modules
  • Event Manager
  • Middleware Manager
  • Service Locator

Top

Install

Install popphp using Composer.

composer require popphp/popphp

Or, require it in your composer.json file

"require": {
    "popphp/popphp" : "^5.0.0"
}

Top

Quickstart

Here's a config file for a basic HTTP web application with some routes in it:

app.http.php

<?php
return [
    'routes' => [
        '/' => [
            'controller' => 'MyApp\Controller\IndexController',
            'action'     => 'index'
        ],
        '*' => [
            'controller' => 'MyApp\Controller\IndexController',
            'action'     => 'error'
        ]
    ]
];

And here's a basic index.php front controller that will drive the application:

index.php

$app = new Pop\Application(include __DIR__ . '/config/app.http.php');
$app->run();

Any request that comes to that front controller will be routed accordingly. For example, the request /:

$ curl -i -X GET http://localhost/

would route to MyApp\Controller\IndexController and execute its index() method.

Any invalid request would route to the same controller's error() method.

Top

Applications

Setting up an HTTP application

Here's an extended example of how to wire up a web application object with a configuration file that defines some basic routes:

app.http.php
<?php
return [
    'routes' => [
        '/' => [
            'controller' => 'MyApp\Controller\IndexController',
            'action'     => 'index'
        ],
        '/users[/]' => [
            'controller' => 'MyApp\Controller\IndexController',
            'action'     => 'users'
        ],
        '/edit/:id' => [
            'controller' => 'MyApp\Controller\IndexController',
            'action'     => 'edit'
        ],
        '*' => [
            'controller' => 'MyApp\Controller\IndexController',
            'action'     => 'error'
        ]
    ]
];

Then you can use include to push the configuration array into the application object. The application object will parse the routes array and register those routes with the application.

The index.php front controller for the web application would look like this:

index.php
$app = new Pop\Application(include __DIR__ . '/config/app.http.php');
$app->run();

An example of a valid request to the above HTTP application would be:

$ curl -i -X GET http://localhost/edit/1001

An example of an invalid request would be:

$ curl -i -X GET http://localhost/bad-request

Top

Setting up a CLI application

Here's an example of how to wire up a CLI-based application object with a configuration file that defines some basic routes:

app.cli.php
<?php
return [
    'routes' => [
        'help' => [
            'controller' => 'MyApp\Controller\ConsoleController',
            'action'     => 'help'
        ],
        'hello <name>' => [
            'controller' => 'MyApp\Controller\ConsoleController',
            'action'     => 'hello'
        ],
        '*' => [
            'controller' => 'MyApp\Controller\ConsoleController',
            'action'     => 'error'
        ]
    ]
];

The app.php front controller (or main script) for the CLI application would look like this:

app.php
$app = new Pop\Application(include __DIR__ . '/config/app.cli.php');
$app->run();

As before, the actions listed in the app.cli.php config above will be routed to methods within the MyApp\Controller\ConsoleController object, help() and hello($name) respectively. And like HTTP, a default error() action can be defined to handle invalid CLI commands.

An example of a valid request to the above CLI application would be:

$ php app.php hello Nick

An example of an invalid request would be:

$ php app.php bad request

Top

CLI Shortcut

Depending on your environment, a CLI front controller or script can be shortened to just a file basename (without the .php extension), for example:

$ ./app

But the script and its contents would have to be properly configured, for example:

#!/usr/bin/php
<?php

/* include any autoloader or other content */

$app = new Pop\Application(include __DIR__ . '/config/app.cli.php');
$app->run();

and set to be executable:

$ chmod 755 ./app

Then the CLI application can be accessed in a shortened, more concise way, like:

$ ./app hello Nick

Top

Flexible Constructor

The application object has a flexible constructor that allows you to inject any of the following in any order:

$app = new Pop\Application(
    $config,     // An array, an array-like object or an instance of Pop\Config\Config
    $autoloader, // An instance of Composer\Autoload\ClassLoader
    $router,     // An instance of Pop\Router\Router
    $services,   // An instance of Pop\Service\Locator
    $events,     // An instance of Pop\Event\Manager
    $modules,    // An instance of Pop\Module\Manager
);

Application Identity

The application object can carry a name, a human-readable full name and a version, each with the usual set/get/has trio:

$app->setName('my-app')                  // A short, slug-like identifier, e.g. for logging or CLI output
    ->setFullName('My Application')      // A human-readable display name, e.g. for a CLI banner or UI header
    ->setVersion('1.2.0');

$app->hasName();      // true
$app->getName();      // 'my-app'
$app->hasFullName();  // true
$app->getFullName();  // 'My Application'
$app->hasVersion();   // true
$app->getVersion();   // '1.2.0'

name, fullName and version can also be set via the application config, and are picked up automatically during bootstrap:

$config = [
    'name'     => 'my-app',
    'fullName' => 'My Application',
    'version'  => '1.2.0',
];

Config always wins over anything already set on the object - including a subclass's own property default for $name/$fullName/$version, which is otherwise left alone rather than overwritten.

If, after that, name and/or fullName are still unset, both fall back to the APP_NAME environment variable (App::name()) - deriving whichever one is missing from whichever shape APP_NAME already has: a value with no space in it (e.g. my-app, or a single word like Pop) is used as-is for name, with fullName title-cased from it (My App, Pop); a value with a space in it (e.g. My App) is used as-is for fullName, with name slugified from it (my-app). This fallback only fills in whichever of the two is still missing - it never overwrites one that config or a subclass default already set.

Top

Merging Application State

An Application object can absorb another application's services, middleware, events and config — useful when one application boots and hands off to another already-configured one (for example, a CLI tool that dispatches into a separate, fully-configured application's own command):

$app->mergeServices($otherApp->services());     // service definitions, overwritten by name on collision
$app->mergeMiddleware($otherApp->middleware()); // middleware handlers, overwritten by name on collision
$app->mergeEvents($otherApp->events());         // listeners combined per event name, not replaced
$app->mergeConfig($otherApp->config());         // config values, overwritten by key on collision by default

mergeConfig() takes an optional $exclude array of top-level config keys to leave untouched, useful for protecting something like routes from being overwritten by the incoming config:

$app->mergeConfig($otherApp->config(), false, ['routes']);

All four can be done in one call with mergeApplication(), which forwards $preserveConfig/$configExclude through to mergeConfig() and skips any piece the source application hasn't set up:

$app->mergeApplication($otherApp, false, ['routes']);

Top

App Helper

There is an "app helper" class that provides access to any environmental variables set in the .env file as well as provides quick access to the current application object from anywhere in your application life cycle. When an application object is created and bootstrapped, it is automatically registered with this static class.

use Pop\App;

$app = App::get(); // Returns the instance of the Pop\Application object

At anytime in the application life cycle, you can use the API of the app helper class to access environmental variables, like this:

use Pop\App;

if (App::env('SOME_VALUE') == 'foo') {
    // Do something
}

Application Environment

The application environment variable sets what type of environment the current running app is in. Supported values for the APP_ENV variable are:

  • local
  • dev
  • testing
  • staging
  • production (or just prod)
use Pop\App;

if (App::isLocal()) {
    // Do something in the local environment
} else if (App::isProduction()) {
    // Do something in the production environment
}

Maintenance Mode

The MAINTENANCE_MODE variable can be set to either true or false to put the application into a controlled "down" state while upgrades and/or maintenance are being performed.

use Pop\App;

if (App::isDown()) {
    // Handle the app in "maintenance mode"
}

To let specific requests through while MAINTENANCE_MODE is on, set a MAINTENANCE_MODE_SECRET env value. Appending ?secret=<value> to a request (once) sets a cookie with that value, and App::isSecretRequest() returns true for any subsequent request - from that client - whose secret (query param or cookie) matches:

use Pop\App;

if (App::isDown() && !App::isSecretRequest()) {
    // Show the maintenance response
}

The full API is:

  • App::config(?string $key = null)
  • App::name()
  • App::url()
  • App::env(string $key, mixed $default = null)
  • App::environment(mixed $env = null)
  • App::isLocal()
  • App::isDev()
  • App::isTesting()
  • App::isStaging()
  • App::isProduction()
  • App::isDown()
  • App::isUp()
  • App::isSecretRequest()

And all of the above except App::isSecretRequest() are also available on the application object instance as well:

  • $app->name()
  • $app->url()
  • $app->env(string $key, mixed $default = null)
  • $app->environment(mixed $env = null)
  • $app->isLocal()
  • $app->isDev()
  • $app->isTesting()
  • $app->isStaging()
  • $app->isProduction()
  • $app->isDown()
  • $app->isUp()

Top

Router

The router object is one of the main components of a Pop application. It serves as the gatekeeper that routes requests to their proper controller. It works for both HTTP web applications and CLI-based applications. The router object will auto-detect the environment and use the correct router matching object for it.

With the app.http.php config above, the actions listed will be routed to methods within the MyApp\Controller\IndexController object, index(), users(), edit($id) and error() respectively.

The route /users[/] allows for an optional trailing slash. The route /edit/:id is expecting a value that will populate the $id parameter that will be passed into the edit($id) method, such as /edit/1001. Failure to have the ID segment of the URL will result in a non-match, or invalid route.

If you don't want to be so strict about the parameters passed into a method or function, you can make the parameter optional like this: /edit[/:id]. The respective method signature would be edit($id = null).

Route Target Shorthand Strings

A route's target doesn't have to be split into separate controller/action keys. Anywhere a route accepts a target value - array configs, wildcard/default routes, the fluent verb methods, and method-group nested routes - a single string in one of these shorthand forms works too, resolved via Pop\Utils\CallableObject:

Shorthand Resolves to
'Class->method' new Class(), then method() on that instance. Route params are passed to the method.
'Class::method' Class::method(). Route params are passed to the method. The method must really be static.
'Class' new Class(). Route params are passed to the constructor; no method is called.
'new Class' Equivalent to a bare 'Class' - an alternate spelling of the same construction.
'routes' => [
    '/users' => 'MyApp\Handler->listUsers',
],

This also works with the fluent verb API, wildcard/default routes, and method-group configs, and it applies to both HTTP and CLI routes:

$app->get('/users', 'MyApp\Handler->listUsers');
$app->addRoute('/users/*', 'MyApp\Handler->listUsers');
$app->addRoutes(['get,post' => ['/users' => 'MyApp\Handler->listUsers']]);

A malformed shorthand string throws a Pop\Utils\Exception at route-registration time, naming the missing class or method, rather than failing silently or crashing later at dispatch. That check is method_exists() only - it does not check that the method is static, so 'Class::method' naming an instance method registers cleanly and then fails at dispatch.

Shorthand strings are not the same as controller routes

Shorthand strings are dispatched through Pop\Utils\CallableObject, which is a different path than the one a controller route takes. A class that extends Pop\Dispatch\AbstractDispatcher - which every Pop\Controller\AbstractController subclass does - is only routed through that dispatcher path when it is named by the controller key, and it still requires an explicit action key:

'routes' => [
    '/users' => [
        'controller' => 'MyApp\Controller\UsersController',
        'action'     => 'index',
    ],
],

Writing 'MyApp\Controller\UsersController->index' instead is not equivalent. The shorthand builds the object with new Class() and calls the method directly, so the controller never has the application injected into it - $this->application(), and with it $this->request, $this->response, services, config and events, are all unavailable. Maintenance mode differs too: a controller route runs the controller's own dispatchMaintenance(), while a shorthand target falls back to the generic 503 Service Unavailable response (negotiated between HTML and JSON the same way as the 404/405 fallbacks described below).

Use shorthand strings for plain handlers and invokables. Use the controller/action keys for anything that extends AbstractController and expects the framework wiring described in Getting the Application, Request/Response or Console in a Controller.

Top

HTTP Routes

Here is a list of possible route syntax options for HTTP applications:

HTTP Route What's Expected
/foo/:bar/:baz The 2 params are required
/foo/:bar[/:baz] First param required, last one is optional
/foo/:bar/:baz* One required param, one required param that is a collection (array)
/foo/:bar[/:baz*] One required param, one optional param that is a collection (array)

HTTP Method Constraints

An HTTP route's controller config can include an optional method key to constrain it to one or more HTTP methods. It accepts a single method string, a comma-separated string, or an array of methods. A route with no method key matches any HTTP method, exactly as before.

'routes' => [
    '/users' => [
        'controller' => 'MyApp\Controller\UsersController',
        'action'     => 'index',
        'method'     => 'get',
    ],
],

The same path can be registered multiple times with different method constraints and different controllers/actions - the standard REST pattern of GET /users listing and POST /users creating both resolve correctly and independently.

A fluent verb API is also available directly on the application, the router, or an Http match instance, matching the array-config method key equivalently:

$app->get('/users', ['controller' => 'MyApp\Controller\UsersController', 'action' => 'index'])
    ->post('/users', ['controller' => 'MyApp\Controller\UsersController', 'action' => 'create']);

head, put, delete, trace, options, connect, and patch are all available the same way. These verb methods are HTTP-only - calling them on an application or router that isn't routed for HTTP throws an exception, since the application object itself remains HTTP/CLI-agnostic.

Custom HTTP methods (e.g. WebDAV verbs) can be registered via addCustomMethod()/addCustomMethods() before being used as a fluent method call:

$app->addCustomMethod('propfind');
$app->propfind('/dav', ['controller' => 'MyApp\Controller\DavController', 'action' => 'index']);

404 vs. 405 - if no registered route's path matches the request URI at all, the response is a standard 404 Not Found. If a route's path matches but none of its method constraints accept the request's HTTP method (and no wildcard/dynamic fallback is available), the response is 405 Method Not Allowed with an Allow header listing the methods that do match that path.

Content negotiation - every one of these framework-level fallback responses (404, 405, and the 503 maintenance-mode response above) renders as HTML only when the request's Accept header states a real preference for text/html, and as JSON otherwise - JSON is the default, not HTML. This is deliberately the opposite of "look for an explicit JSON preference": real browsers always state text/html explicitly in their Accept header, while non-browser HTTP clients (curl, most API callers) are inconsistent about declaring application/json and commonly send a bare */* or no Accept header at all - treating that as "wants HTML" would silently serve API callers an HTML error page. The check is resolved via Pop\Http\Server\AcceptHeader, using AcceptSpecificity::Loose so a bare */* doesn't count as a real HTML preference (an explicit text/html, or text/*, does).

Route matching order - when more than one registered route could match a given request, the most specific one wins, regardless of the order routes were declared in. Specificity is decided by walking each route's path segments left to right: a static segment beats a required parameter, which beats an optional parameter, which beats an array/wildcard parameter (:param*) - whichever route has the more specific segment at the first point the two routes differ wins, not whichever has more or fewer parameters overall. For example, /orders/:id and /orders/trash[/:id] both have one parameter, but /orders/trash[/:id] wins for /orders/trash because its first segment (trash) is static where the other route's is a parameter (:id). Declaration order is only used as a tiebreaker between routes of identical segment-by-segment specificity.

Popcorn-style method-grouped routes are also supported, for apps migrating a popphp/popcorn-style routes config: a top-level key that's a bare, comma-separated list of HTTP methods (never a real route path, which always starts with /, is *, or contains :controller) wraps a nested array of routes, applying that method list to every route inside it:

'routes' => [
    'options,get' => [
        '/users' => ['controller' => 'MyApp\Controller\UsersController', 'action' => 'index'],
        '/roles' => ['controller' => 'MyApp\Controller\RolesController', 'action' => 'index'],
    ],
    'options,post' => [
        '/users/create' => ['controller' => 'MyApp\Controller\UsersController', 'action' => 'create'],
    ],
],

This is equivalent to (and expands internally into) giving each nested route its own method key directly - if a nested route also sets its own method key, the group's method list wins. Both forms can be mixed freely in the same routes config.

The routes under a method group can themselves be further nested, the same way ordinary (non-method-grouped) routes can - the method list is applied to every leaf config found at any depth:

'routes' => [
    'get,options' => [
        '/users' => [
            '[/]'    => ['controller' => 'MyApp\Controller\UsersController', 'action' => 'index'],
            '/count' => ['controller' => 'MyApp\Controller\UsersController', 'action' => 'count'],
        ],
    ],
    'post,options' => [
        '/users' => [
            '/create' => ['controller' => 'MyApp\Controller\UsersController', 'action' => 'create'],
            '/update' => ['controller' => 'MyApp\Controller\UsersController', 'action' => 'update'],
        ],
    ],
],

Top

CLI Routes

Here is a list of possible route syntax options for CLI applications:

CLI Route What's Expected
foo bar Two commands are required
foo bar|baz Two commands are required, the 2nd can accept 2 values
foo [bar|baz] The second command is optional and can accept 2 values
foo <name> [<email>] First parameter required, 2nd parameter optional
foo --name=|-n [-e|--email=] First option value required, 2nd option value is optional
foo [--option|-o] Option with both long and short formats

When more than one registered CLI route could match a given command, the most specific one wins regardless of declaration order, same as the HTTP route matching order described above.

Options are passed as the last parameter injected into the route parameters of the route method or function. The $options parameter will be an array. When the options are simple flags, the values in the array are booleans:

function($name, $email = null, array $options = []) { }
./foo -p --verbose John john@test.com
$options = [
    'p'       => true,
    'verbose' => true,
];

Option values will populate the $options parameter in key/value pairs, like this:

./foo [-n|--name=]
./foo -nJohn
./foo --name=John
$options = ['name' => 'John'];

Top

Dynamic Routing

There is support for dynamic routing for both HTTP and CLI applications. The reserved route keywords controller and action are used to map the route to a matched controller class and respective action method within that class. You could define a dynamic HTTP route like this:

<?php
return [
    'routes' => [
        '/:controller/:action[/:param]' => [
            'prefix' => 'MyApp\Controller\\'
        ]
    ]
];

which will map a route like

/users/edit/1001
MyApp\Controller\UsersController->edit($id)

A dynamic CLI route like would work in a similar fashion:

<?php
return [
    'routes' => [
        'foo <controller> <action> [<param>]' => [
            'prefix' => 'MyApp\Controller\\'
        ]
    ]
];

which will map a route like

./app foo users edit 1001
MyApp\Controller\UsersController->edit($id)

Any literal segment declared in a dynamic route - the /admin in /admin/:controller/:action or the foo above - has to be present in the request for that route to apply, and the controller and action are read from the positions where :controller/<controller> and :action/<action> are declared, not from the first two segments of the request. So /admin/:controller/:action maps /admin/users/edit to MyApp\Controller\UsersController->edit() and does not match /users/edit at all.

Forcing a Route

Application::run(bool $exit = true, string|array|null $forceRoute = null) and Router::route(string|array|null $forceRoute = null) both accept an optional route to match instead of the real request ($_SERVER['argv'] for CLI, $_SERVER['REQUEST_URI'] for HTTP). This is useful for dispatching a route programmatically - for example, running a CLI command from a queued job against an existing Application instance, outside of a real console invocation.

A forced route carries its own params and options exactly like a real request does - a forced CLI command is parsed for its <param>s and [--option]s, and a forced HTTP path is parsed for its :params, the same way the real, unforced route would be:

$app->run(false, 'send:email --quiet 1001');   // string form
$app->run(false, ['send:email', '-q', '1001']); // array form
$app->run(false, '/user/42');       // string form
$app->run(false, ['user', '42']);   // array form

The array form takes pre-split segments (exactly like real argv), and is the only way to pass a value containing spaces - the string form is split on whitespace, so it has no quoting support ('send:email -q "John Smith"' will not work; pass ['send:email', '-q', 'John Smith'] instead).

A forced HTTP route is matched as-is - unlike the real REQUEST_URI, it is not stripped of the app's basePath first. If the app is deployed under a subdirectory, pass the path relative to that subdirectory ('/user/42', not '/subdir/user/42').

Each call to route()/match() re-parses its params/options fresh and clears any controller/action/params left over from a previous call, so it's safe to call run() repeatedly against the same Application instance (e.g. one job worker dispatching many commands) without a failed or different route on one call leaking state into the next. This covers routing state only - per-route middleware config is still added to the application's shared middleware manager on every match and isn't cleared between calls.

Top

Controllers

The controller object is the 'C' in the MVC design pattern and gives you the ability to encapsulate the behavior and functionality of how the routes behave and are handled. But it should be noted that you don't have to use a full controller object. For smaller applications, you can use anything that is callable, like a closure. An example of that would be:

use Pop\Application;
use Pop\Router\Router;

$routes = [
    '/hello' => [
        'controller' => function() {
            echo 'Hello World';
        }
    ],
    '/hello/:name' => [
        'controller' => function($name) {
            echo 'Hello ' . $name;
        }
    ]
];

$app = new Application(new Router($routes));
$app->run();

But, for most large-scale applications, it would be best to use a full controller object to manage the overall behavior or what is to happen for specific routes. The base controller object is an abstract controller class Pop\Controller\AbstractController, which extends Pop\Dispatch\AbstractDispatcher and implements Pop\Dispatch\DispatchableInterface/Pop\Dispatch\MaintenanceInterface (the shared Pop\Dispatch namespace that backs any dispatchable, maintenance-aware object, not just controllers). The base functionality is fairly simple and allows you to build and structure your controller as needed. The only base functionality wired in is a dispatch method that handles the actual dispatching of the appropriate method and also the default action methods to set up what happens with a route/method isn't matched (typically used for error handling.)

Maintenance mode (see Maintenance Mode above) is handled automatically by Application::run() - as soon as MAINTENANCE_MODE is on, every matched route is redirected to the maintenance response, with no extra setup required. For a controller-based route, that means your own maintenance() action (below) runs instead of the normal action; closure and callable routes get a generic "Service Unavailable" response instead (negotiated between HTML and JSON per the request's Accept header, same as the router's 404/405 fallbacks), since they have no action of their own to redirect to. Call $controller->setBypassMaintenance(true) (typically in your controller's constructor) to exempt a specific controller from maintenance mode entirely.

Let's take a look at what the MyApp\Controller\IndexController class from the above web example might look like:

<?php

namespace MyApp\Controller;

use Pop\Controller\AbstractController;

class IndexController extends AbstractController
{

    // This is the default value
    protected string $defaultAction = 'error';

    // This is the default value
    protected string $maintenanceAction = 'maintenance';


    public function index()
    {
        // Do something for the index page
    }

    public function users()
    {
        // Do something for the users page
    }

    public function edit($id)
    {
        // Edit user with $id
    }

    public function error()
    {
        // Handle a non-match route request
    }

    public function maintenance()
    {
        // Handle requests that come in while the application is in maintenance mode
    }

}

Getting the Application, Request/Response or Console in a Controller

AbstractController (via Dispatch\AbstractDispatcher) takes an optional ?Application $application as its constructor's only argument by default, so $this->application()/$this->getApplication() work even on a plain controller with no trait. To additionally get the request/response objects (HTTP) or the console object (CLI) injected, use the matching trait:

<?php

namespace MyApp\Controller;

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

class IndexController extends AbstractController
{
    use HttpTrait;

    public function index()
    {
        $name = $this->application()->getName();
        $uri  = $this->request()->getUriString();
        $this->response()->setBody('Hello from ' . $name . ' at ' . $uri);
    }
}

Pop\Dispatch\ConsoleTrait is the CLI equivalent - it injects $application and a Console object instead, accessible via $this->application() and $this->console(). Both traits live under Pop\Dispatch\ (not Pop\Controller\) so they can be reused by any dispatchable class, not just controllers.

The router detects whether to inject $application by reflecting on the dispatchable's actual constructor - if its first parameter accepts an Application instance (whether that's the inherited AbstractDispatcher default, HttpTrait/ConsoleTrait's constructor, or a fully custom constructor typed the same way), it's passed in; otherwise the controller is instantiated with no constructor arguments at all.

If a controller needs custom constructor arguments instead (for a dependency that isn't the application/request/response/console, or one whose constructor doesn't accept Application as its first parameter), bypass the detection entirely with the route's params key:

'routes' => [
    '/users' => [
        'controller' => 'MyApp\Controller\UsersController',
        'action'     => 'index',
        'params'     => [$userService, $logger],
    ],
],

or via addDispatchableParams() directly on the router:

$app->router()->addDispatchableParams('MyApp\Controller\UsersController', [$userService, $logger]);

addDispatchableParams('*', [...]) registers a default parameter set that applies to any controller that doesn't have its own explicit entry - it's only settable this way, not via the route array's params key. When explicit params (or the '*' default) are present for a controller, they always take priority over the automatic Application injection.

Top

Modules

Modules can be thought of as "mini-application objects" that allow you to extend the functionality of your application. Module objects accept similar configuration parameters as an application object, such as routes, services and events. Additionally, it accepts a prefix configuration value as well to allow the module to register itself with the application autoloader. Here's an example of what a module might look like and how you'd register it with an application:

Configuration Array

In the example below, the module configuration is passed into the application object. From there, an instance of the base module object is created and the configuration is passed into it. The newly created module object is then registered with the module manager within the application object.

$application = new Pop\Application();

$moduleConfig = [
    'routes' => [
        '/' => [
            'controller' => 'MyModule\Controller\IndexController',
            'action'     => 'index'
        ]
    ],
    'prefix' => 'MyModule\\'
];

$application->register($moduleConfig, 'my-module');

Module Instance

In the example below, a module object is created and passed directly into the application object. The module object is then registered with the module manager within the application object.

$application = new Pop\Application();

$myModule = new Pop\Module\Module([
    'name'   => 'my-module',
    'routes' => [
        '/' => [
            'controller' => 'MyModule\Controller\IndexController',
            'action'     => 'index'
        ]
    ],
    'prefix' => 'MyModule\\'
]);

$application->register($myModule);

Top

Custom Modules

You can pass your own custom module objects into the application as well, as long as they implement the interface Pop\Module\ModuleInterface provided. As the example below shows, you can create a new instance of your custom module and pass that into the application. The benefit of doing this is to allow you to extend the base module class and methods and provide any additional functionality that may be needed. In doing it this way, however, you will have to register your module's namespace prefix with the application's autoloader prior to registering the module with the application so that the application can properly detect and load the module's source files.

$application->autoloader->addPsr4('MyModule\\', __DIR__ . '/modules/mymodule/src');

$myModule = new MyModule\Module([
    'routes' => [
        '/' => [
            'controller' => 'MyModule\Controller\IndexController',
            'action'     => 'index'
        ]
    ]
]);

$application->register($myModule, 'myModule');

Top

Module Manager

The module manager provides a way to extend the core functionality of your application. The module manager object is really a collection object of actual module objects that serves as the bridge to integrate the modules with the application. You can think of the module objects themselves as "mini application objects" because, like the application object, they can take a configuration array that will wire up routes and other settings specific to the module.

Here's an example of a way to inject a module into an application. You'll want to register the autoloader with the application so that it can handle the appropriate loading of the module files and classes within the application.

// Using Composer's autoloader
$autoloader = require __DIR__ . '/vendor/autoload.php';

$app = new Pop\Application($autoloader, include __DIR__ . '/config/app.php');

// $myModuleConfig contains the config settings for the
// module, such as the autoload prefix and the routes
$app->register(new MyModule($myModuleConfig));

Top

Event Manager

The event manager provides a way to hook specific events and functionality into certain points in the application's life cycle. The default hook points with the application object are:

  • app.init
  • app.route.pre
  • app.dispatch.pre
  • app.dispatch.post
  • app.error
  • app.shutdown

You can simply register callable objects with the event manager to have them be called at that time in the application's life cycle:

$app->on('app.route.pre', function($application) {
    // Do some pre-route stuff
});

app.error does not suppress the exception. If anything throws during routing or dispatch, Application::run() catches it, fires app.error with the exception available to your listeners, and then rethrows the same exception - it does not swallow it. app.error is a place to react (log it, send a notification, etc.), not a place to handle it and move on. Wrap your own $app->run() call in a try/catch if you want the exception to stop there instead of propagating to your calling script:

$app->on('app.error', function($exception, $application) {
    // Log it, notify someone, etc. - the exception still propagates after this runs.
});

try {
    $app->run();
} catch (\Throwable $exception) {
    // Handle it here if you don't want it to escape further.
}

Stopping propagation. A listener can halt any remaining listeners for the event currently firing by calling stopPropagation() on the event object, available as an extra parameter after $result:

$app->on('app.route.pre', function($application, $result, $event) {
    $event->stopPropagation();
});

Aborting the application. A listener can throw Pop\Event\AbortException to halt Application::run() outright - routing/dispatch stops immediately, run() returns without throwing, and app.error does not fire:

use Pop\Event\AbortException;

$app->on('app.route.pre', function($application) {
    if ($application->isDown()) {
        throw new AbortException('Application is in maintenance mode.');
    }
});

Guaranteed teardown. app.shutdown fires exactly once at the end of every run() call, regardless of how it ended - normal completion, an AbortException, or a rethrown error from app.error. Use it for cleanup that must always happen (closing connections, flushing logs), not for anything that depends on the request having succeeded:

$app->on('app.shutdown', function($application) {
    // Runs no matter what happened above - success, abort, or error.
});

PSR-14 Compatibility

Pop\Event\Manager is a genuine, spec-compliant PSR-14 Psr\EventDispatcher\EventDispatcherInterface and ListenerProviderInterface implementation - not a second, parallel system. on()/off()/trigger() above and the typed-event API below both run through the same dispatch() call for each of the six built-in hook points as fired by Application, so a listener registered either way sees the same event firing:

use Pop\Event\RoutePreEvent;

$app->events()->listen(RoutePreEvent::class, function(RoutePreEvent $event) {
    // Do some pre-route stuff - $event->application() is the Pop\Application instance
});

There is one dispatchable event class per existing app.* hook point (InitEvent, RoutePreEvent, DispatchPreEvent, DispatchPostEvent, ErrorEvent - the last of which also exposes exception() - and ShutdownEvent), all under Pop\Event\. Listener resolution for listen() is by exact event class only.

Note that calling Manager::trigger($name, $params) directly (i.e., not through Application) always builds a generic Pop\Event\Event, never one of the typed classes above - so listen(SomeTypedEvent::class, ...) only ever fires for events something actually constructs and dispatches as that typed class, which is what Application does for its six hooks. A same-named trigger('app.dispatch.pre') call made by other code will not reach a listen(DispatchPreEvent::class, ...) listener at all.

Top

Middleware Manager

The middleware manager provides a way to hook specific functionality in and around the dispatch action in an application object. Middleware themselves are classes that would implement the following interfaces:

  • Pop\Middleware\MiddlewareInterface - required, defines the handle() method that will be called to execute the middleware
  • Pop\Middleware\TerminableInterface - optional, defines the terminate() method that can be called to execute any post-dispatch code

Example middleware class:

class TestMiddleware implements MiddlewareInterface, TerminableInterface
{

    public function handle(mixed $request, \Closure $next): mixed
    {
        echo 'Entering Test Middleware.<br />';
        $response = $next($request);
        echo 'Exiting Test Middleware.<br />';
        return $response;
    }

    public function terminate(mixed $request = null, mixed $response = null): void
    {
        file_put_contents(
            __DIR__ . '/logs/mw.log',
            'Executing terminate method for test middleware.' . PHP_EOL,
            FILE_APPEND
        );
    }
}

Middleware can be added directly to the application object, or via the application config:

$app = new Pop\Application();
$app->middleware->addHandler('TestMiddleware');
$config = [
    'middleware' => ['TestMiddleware'],
    'routes'     => [
        '/' => [
            'controller' => function() {
                echo 'Index Page.<br />';
            }
        ],
    ]
];
$app = new Pop\Application($config);
$app->run();

When making the request to the above application (e.g., http://localhost:8000/), the response will be:

Entering Test Middleware.
Index page.
Exiting Test Middleware.

Furthermore, the terminate() method will have been executed post-dispatch and added the following entry to the logs/mw.log log file:

Executing terminate method for test middleware.

Middleware can be applied globally or on a specific route-level. Middleware assigned to a specific route will only execute on that route.

$config = [
    'middleware' => ['TestMiddleware'],
    'routes'     => [
        '/' => [
            'controller' => function() {
                echo 'Index Page.<br />';
            }
        ],
        '/admin[/]' => [
            'middleware' => 'AdminMiddleware',
            'controller' => function() {
                echo 'Admin Page.<br />';
            }
        ],
    ]
];
$app = new Pop\Application($config);
$app->run();

PSR-15 Compatibility

Pop\Middleware\Psr15\MiddlewareAdapter lets a real PSR-15 Psr\Http\Server\MiddlewareInterface handler run inside the native middleware queue above, alongside ordinary Pop middleware:

use Pop\Middleware\Psr15\MiddlewareAdapter;

$app->middleware->addHandler(new MiddlewareAdapter(new SomeThirdPartyPsr15Middleware()));

On an HTTP request, the objects Application::run() supplies are already PSR-7. PSR-15 requires Psr\Http\Message\ServerRequestInterface/ResponseInterface (PSR-7) objects, and Pop's own HTTP pair implements both: Pop\Http\Server\Request implements ServerRequestInterface, and Pop\Http\Server\Response implements ResponseInterface. So a PSR-15 middleware registered this way receives a genuine ServerRequestInterface from Application::run() itself, with no shim needed - though a request or response from a library like nyholm/psr7 works equally well, since only the interface is ever checked.

This does not carry over to the CLI. Application::run() resolves the middleware request from the matched dispatchable, and a console route supplies a Pop\Console\Console, which is not a ServerRequestInterface - the wrapped PSR-15 middleware will reject it on its own parameter type declaration. Register PSR-15 middleware in HTTP applications only, or drive Middleware\Manager::process() yourself with a request object you supply. Pop\Middleware\Psr15\RequestHandler is the companion PSR-15 RequestHandlerInterface implementation the adapter uses internally to expose Pop's own $next continuation to the wrapped PSR-15 middleware.

The route target itself must return a PSR-7 response when PSR-15 middleware is registered. RequestHandlerInterface::handle() is contractually required to return a Psr\Http\Message\ResponseInterface, but a controller-class route target (an AbstractController/AbstractDispatcher subclass) dispatches through a void method and can never produce one. Application::run() detects this combination - a PSR-15 MiddlewareAdapter registered alongside a controller-class route target - and throws a Pop\Middleware\Exception before the route is dispatched at all, rather than letting the controller run and then fail with a PHP TypeError partway through the middleware stack unwinding. Use a closure (or a callable-string) route target that returns a real ResponseInterface instead - Pop\Http\Server\Response is one:

$app->middleware->addHandler(new MiddlewareAdapter(new SomeThirdPartyPsr15Middleware()));

$app->get('/', function() {
    return new Pop\Http\Server\Response(['code' => 200, 'body' => 'OK']);
});

Only the controller-class combination is detected ahead of time. A closure route target that returns nothing still fails on the way back out, after the closure has run, with TypeError: Pop\Middleware\Psr15\RequestHandler::handle(): Return value must be of type Psr\Http\Message\ResponseInterface, null returned - so return a response from every path.

Top

Service Locator

The service locator provides a way to make common services available throughout the application's life cycle. You can set them up at the beginning of the application and call them any time during the application's life cycle.

$app->setService('foo', 'MyApp\FooService');

From inside a controller object:

<?php
namespace MyApp\Controller;

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

class IndexController extends AbstractController
{
    use HttpTrait;

    public function index()
    {
        $foo = $this->application()->getService('foo');
        // Do something with the 'foo' service
    }
}

Service Container

If you are in an area of the application where you don't have direct access to the application's service locator, you can use the globally available service container:

<?php
namespace MyApp\Controller;

use Pop\Service\Container;
use Pop\Controller\AbstractController;

class IndexController extends AbstractController
{
    public function index()
    {
        // 'default' is the default service container. Other service containers may be available.
        $foo = Container::get('default')->get('foo');
        // Do something with the 'foo' service
    }
}

PSR-11 Compatibility

Pop\Service\Locator implements PSR-11's Psr\Container\ContainerInterface, so it can be type-hinted and passed anywhere a PSR-11 container is expected. get() throws Pop\Service\NotFoundException (which implements Psr\Container\NotFoundExceptionInterface) for an unregistered service name, and Pop\Service\Exception (which implements Psr\Container\ContainerExceptionInterface) for other retrieval errors - both are still catchable as their existing, non-PSR types, so this is not a breaking change.

Top

Configuration Tips

In the above examples, both the application and module config arrays can have a routes key set that defines the routes of the application or module. Additionally, the keys events and services are allowed as well, so an application or module can be wired up all from the configuration array:

<?php
return [
    'routes'   => [
        '/' => [
            'controller' => 'MyApp\Controller\IndexController',
            'action'     => 'index'
        ],
        '/users[/]' => [
            'controller' => 'MyApp\Controller\IndexController',
            'action'     => 'users'
        ],
        '/edit/:id' => [
            'controller' => 'MyApp\Controller\IndexController',
            'action'     => 'edit'
        ],
        '*' => [
            'controller' => 'MyApp\Controller\IndexController',
            'action'     => 'error'
        ]
    ],
    'services' => [
        'session' => [
            'call' => 'Pop\Session\Session::getInstance'
        ]
    ],
    'events' => [
        [
            'name'     => 'app.route.post',
            'action'   => 'MyApp\Event\Foo::bootstrap',
            'priority' => 1000
        ]
    ]
];

The config also supports the keys prefix, psr-0 and src for autoloading purposes. The default is to autoload with PSR-4, unless the psr-0 key is set to true.

<?php
return [
    'prefix' => 'MyModule\\',
    'src'    => __DIR__ . '/../src',
];

Helper Functions

The helper functions available from the pop-utils component are automatically loaded within the application object's boostrap call. If this is not desired, a configuration setting called helper_functions (set to false) can be passed to prevent them from loading:

$app = new Pop\Application([
    'helper_functions' => false
]);

Top

About

Pop PHP Framework Core Components

Resources

Stars

68 stars

Watchers

12 watching

Forks

Releases

Packages

Used by

Contributors

Languages