brick / app
Web application framework
Installs: 1 587
Dependents: 0
Suggesters: 0
Security: 0
Stars: 25
Watchers: 5
Forks: 5
Open Issues: 0
Requires
- php: ^8.0
- brick/di: ~0.4.0
- brick/event: ~0.1.0
- brick/html: ~0.1.0
- brick/http: ~0.3.1
- brick/reflection: ~0.4.0
Requires (Dev)
- ext-pdo: *
- brick/date-time: 0.1.*
- brick/form: 0.1.*
- brick/geo: 0.2.*
- doctrine/orm: 2.*
- php-coveralls/php-coveralls: ^2.4
- phpunit/phpunit: ^9.0
Suggests
- brick/date-time: To use the DateTimeObjectPacker
- brick/form: To use FormView and FormTableView
- brick/geo: To use the GeometryObjectPacker
- doctrine/orm: To use the DoctrineObjectPacker
README
A web application framework.
Installation
This library is installable via Composer:
composer require brick/app
Requirements
This library requires PHP 8.0 or later.
Project status & release process
This library is still under development.
The current releases are numbered 0.x.y
. When a non-breaking change is introduced (adding new methods, optimizing existing code, etc.), y
is incremented.
When a breaking change is introduced, a new 0.x
version cycle is always started.
It is therefore safe to lock your project to a given release cycle, such as 0.6.*
.
If you need to upgrade to a newer release cycle, check the release history
for a list of changes introduced by each further 0.x.0
version.
Overview
Setup
We assume that you have already installed the library with Composer.
Let's create an index.php
file that contains the simplest possible application:
use Brick\App\Application; require 'vendor/autoload.php'; $application = Application::create(); $application->run();
If you run this file in your browser, you should get a 404
page that details an HttpNotFoundException
. That's perfectly normal, our application is empty.
Before adding more stuff to our application, let's create a .htaccess
file to tell Apache to redirect all requests that do not target an existing file, to our index.php
file:
RewriteEngine on
RewriteCond %{REQUEST_FILENAME} !-f
RewriteCond %{REQUEST_FILENAME} !-d
RewriteRule ^.*$ /index.php [L]
Now if you open any path in your browser, you should get a similar exception page. What we created here is called a front controller, and is a really handy pattern to ensure that all requests enter your application by the same door.
Creating controllers
A controller is a piece of code that returns a Response
for an incoming Request
. This is where you put all the glue logic to work with models, interact with the database, and generate HTML content.
A controller can be any callable
, but is generally a class with a number of methods that correspond to different pages or actions.
Let's create a simple controller class:
namespace MyApp\Controller; use Brick\Http\Response; class IndexController { public function indexAction() { return new Response('This is the index page.'); } public function helloAction() { return new Response('Hello, world'); } }
The controller class does not need to extend any particular class, and the only requirement on the controller method is that it must return a Response
object. This requirement can even be alleviated by using a plugin that creates a Response from the controller return value.
Adding routes
The next step is to instruct our application what controller to invoke for a given request. An object that maps a request to a controller is called a Route
.
The application ships with a few routes that cover the most common use cases. If you have more complex requirements, you can easily write your own routes.
Let's add an off-the-box route that maps a path directory to a controller:
use Brick\App\Route\SimpleRoute; $route = new SimpleRoute([ '/' => MyApp\Controller\IndexController::class, ]); $application->addRoute($route);
Open your browser at /
, you should get the index page message.
Open your browser at /hello
, you should get the "Hello, world" message.
Getting request data
Returning information is great, but most of the time you will need to get data from the current request first. It's very easy to get access to the Request
object, just add it as a parameter to your method:
public function helloAction(Request $request) { return new Response('Hello, ' . $request->getQuery('name')); }
Now if you open your browser at /hello?name=John
, you should get a "Hello, John" message.
Adding plugins
The application can already do interesting things, but is still pretty dumb. Fortunately, there is a great way to extend it with extra functionality: plugins.
The application ships with a few useful plugins. Let's have a look at one of them: the RequestParamPlugin
. This plugin allows you to automatically map request parameters to your controller parameters, with attributes.
Let's add this plugin to our application:
use Brick\App\Plugin\RequestParamPlugin; $plugin = new RequestParamPlugin(); $application->addPlugin($plugin);
That's it. Let's update our helloAction()
once again to use this new functionality:
namespace MyApp\Controller; use Brick\App\Controller\Attribute\QueryParam; use Brick\Http\Response; class Index { #[QueryParam('name')] public function helloAction(string $name) { return new Response('Hello, ' . $name); } }
If you open your browser at /hello?name=Bob
, you should get "Hello, Bob". We did not need to interact directly with the Request object anymore. Request variables are now automatically injected in our controller parameters. Magic.
Writing your own plugins
You can extend the application indefinitely with the use of plugins. It's easy to write your own, as we will see through this example. Let's imagine that we want to create a plugin that begins a PDO transaction before the controller is invoked, and commits it automatically after the controller returns.
First let's have a look at the Plugin
interface:
interface Plugin { public function register(EventDispatcher $dispatcher) : void; }
Just one method to implement. This method allows you to register your plugin inside the application's event dispatcher, that is, tell the application which events it wants to receive. Here is an overview of the events dispatched by the application.
IncomingRequestEvent
This event is dispatched as soon as the application receives a Request.
This event contains the Request
object.
RouteMatchedEvent
This event is dispatched after the router has returned a match. If no match is found, the request handling is interrupted, and ExceptionCaughtEvent
is dispatched with an HttpNotFoundException
.
This event contains the Request
and the RouteMatch
objects.
ControllerReadyEvent
This event is dispatched when the controller is ready to be invoked. If the controller is a class method, the class will have been instantiated and this controller instance is made available to the event.
This event contains the Request
and the RouteMatch
objects, and the controller instance if the controller is a class method.
NonResponseResultEvent
This event is dispatched if the controller does not return a Response
object. This event provides an opportunity for plugins to transform an arbitrary controller result into a Response
object. For example, it could be used to JSON-encode the controller return value and wrap it into a Response
object with the proper Content-Type header.
This event contains the Request
and the RouteMatch
objects, the controller instance if the controller is a class method, and the return value of the controller.
ControllerInvocatedEvent
This event is dispatched after controller invocation, regardless of whether an exception was thrown or not.
This event contains the Request
and the RouteMatch
objects, and the controller instance if the controller is a class method.
ResponseReceivedEvent
This event is dispatched after the controller response has been received. If an HttpException
is caught during the controller method invocation, the exception it is converted to a Response
, and this event is dispatched as well. Other exceptions break the application flow and don't trigger this event.
This event contains the Request
, Response
and RouteMatch
objects, and the controller instance if the controller is a class method.
ExceptionCaughtEvent
This event is dispatched if an exception is caught. If the exception is not an HttpException
, it is wrapped in an HttpInternalServerErrorException
first, so that this event always receives an HttpException
. A default response is created to display the details of the exception.
This event provides an opportunity to modify the default response to present a customized error message to the client.
This event contains the HttpException
, Request
and Response
objects.
We can see that the two events that are called immediately before and after the controller is invoked are:
ControllerReadyEvent
ControllerInvocatedEvent
What we just need to do is to map each of these events to a function that does the job. Let's do it:
use Brick\App\Event\ControllerReadyEvent; use Brick\App\Event\ControllerInvocatedEvent; use Brick\App\Plugin; use Brick\Event\EventDispatcher; class TransactionPlugin implements Plugin { private $pdo; public function __construct(\PDO $pdo) { $this->pdo = $pdo; } public function register(EventDispatcher $dispatcher) : void { $dispatcher->addListener(ControllerReadyEvent::class, function() { $this->pdo->beginTransaction(); }); $dispatcher->addListener(ControllerInvocatedEvent::class, function() { $this->pdo->commit(); }); } }
Easy as pie! Let's add our plugin to our application:
$pdo = new PDO(/* insert parameters to connect to your database */); $plugin = new TransactionPlugin($pdo); $application->addPlugin($plugin);
We just implemented a plugin, available to all controllers in our application, in no time. This implementation is of course still naive, but does what it says on the tin, and is a good starting point for more advanced functionality.
Sessions
The framework features a powerful alternative to native PHP sessions, allowing synchronized and non-blocking read/write to individual session entries, and supports a pluggable storage mechanism.
What's wrong with native PHP sessions?
Native sessions are stored in a single block of data, and session files are locked for the entire duration of the PHP script. As a consequence, all requests for a single session are serialized: if several requests targeting the same session are received concurrently, they are queued and processed one after the other. This is good enough in a traditional page-to-page browsing situation, but may cause bottlenecks when a web page issues potentially concurrent HTTP calls using AJAX.
Brick\App's session manager works differently:
- each key-value pair in the session is stored independently
- each key-value pair is only loaded when explicitly requested
- each key-value pair can be read or written without locking using
has()
,get()
,set()
andremove()
- when locking is required, a key-value pair can be read and written using the
synchronize()
method:
$session->synchronize('session-key', function($currentValue) { // ... return $newValue; });
Only the given key is locked, and the lock is released as soon as the function returns.
Installing the session plugin
To store your sessions in the filesystem, alongside traditional PHP sessions, just use:
use Brick\App\Session\CookieSession; use Brick\App\Plugin\SessionPlugin; $session = new CookieSession(); $app->addPlugin(new SessionPlugin($session));
You can alternatively provide a custom storage adapter and use new CookieSession($storage)
instead. A filesystem adapter and a database (PDO) adapter are provided; you can also write your own adapter by implementing SessionStorage
.
Using the sessions
If you're using dependency injection in your app, you can have the Session
object passed to your controller easily.
Just register the container in your application, and instruct it to resolve sessions:
use Brick\DI\Container; use Brick\App\Application; use Brick\App\Session\CookieSession; use Brick\App\Session\Session; use Brick\App\Plugin\SessionPlugin; // Create a DI container, and use it with our app $container = Container::create(); $app = Application::create($container); // Create a session, add the session plugin to our app $session = new CookieSession(); $app->addPlugin(new SessionPlugin($session)); // Instruct the DI container to resolve the Session object $container->set(Session::class, $session);
Now the app can resolve your session automatically in your controller functions:
public function indexAction(Request $request, Session $session) { $userId = $session->get('user-id'); // ... }