- Overview
- Install
- Quickstart
- Applications
- App Helper
- Router
- Controllers
- Modules
- Event Manager
- Middleware Manager
- Service Locator
- Configuration Tips
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
Install popphp using Composer.
composer require popphp/popphp
Or, require it in your composer.json file
"require": {
"popphp/popphp" : "^5.0.0"
}
Here's a config file for a basic HTTP web application with some routes in it:
<?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:
$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.
Here's an extended example of how to wire up a web application object with a configuration file that defines some basic routes:
<?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:
$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/1001An example of an invalid request would be:
$ curl -i -X GET http://localhost/bad-requestHere's an example of how to wire up a CLI-based application object with a configuration file that defines some basic routes:
<?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 = 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 NickAn example of an invalid request would be:
$ php app.php bad requestDepending on your environment, a CLI front controller or script can be shortened to just a file basename
(without the .php extension), for example:
$ ./appBut 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 ./appThen the CLI application can be accessed in a shortened, more concise way, like:
$ ./app hello NickThe 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
);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.
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 defaultmergeConfig() 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']);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 objectAt 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
}The application environment variable sets what type of environment the current running app is in. Supported values
for the APP_ENV variable are:
localdevtestingstagingproduction(or justprod)
use Pop\App;
if (App::isLocal()) {
// Do something in the local environment
} else if (App::isProduction()) {
// Do something in the production environment
}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()
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).
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 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.
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) |
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'],
],
],
],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'];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.
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 formThe 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.
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
}
}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.
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);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');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));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.
});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.
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 thehandle()method that will be called to execute the middlewarePop\Middleware\TerminableInterface- optional, defines theterminate()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();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.
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
}
}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
}
}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.
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',
];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
]);