DOCUMENTATION / APERÇU
Zephyrus/Documentation/L’application

Un premier aperçu du guide. Le framework évolue. Les chapitres et les exemples d’API seront finalisés avec la prochaine mise à jour.

L’application

Un builder, un handle().

Une application Zephyrus est un objet qui transforme une Request en Response. Vous l’assemblez avec ApplicationBuilder, puis vous envoyez sa réponse au navigateur ou, dans les tests, vous la lisez. Il n’y a pas de contrôleur frontal du framework à étendre : le code de liaison est une petite classe qui vous appartient.

Une classe de démarrage

Voici un démarrage complet. public/index.php l’appelle, et vos tests appellent directement application().

namespace App;

use App\Exceptions\OrderNotFound;
use App\Services\Greeter;
use Zephyrus\Container\Container;
use Zephyrus\Core\Application;
use Zephyrus\Core\ApplicationBuilder;
use Zephyrus\Core\Config\Configuration;
use Zephyrus\Core\ExceptionEvent;
use Zephyrus\Event\EventDispatcher;
use Zephyrus\Http\Request;
use Zephyrus\Http\Response;
use Zephyrus\Routing\Router;
use Zephyrus\Security\CsrfConfig;
use Zephyrus\Security\CsrfMiddleware;
use Zephyrus\Security\MaxBodySizeMiddleware;
use Zephyrus\Security\SecureHeadersMiddleware;
use Zephyrus\Session\SessionCsrfTokenManager;
use Zephyrus\Session\SessionManager;
use Zephyrus\Session\SessionMiddleware;

final class Kernel
{
    public static function run(string $root): void
    {
        $config = Configuration::fromYamlFile($root . '/config.yml');

        $request = Request::fromGlobals(
            trustedProxies: $config->security->trustedProxies,
            trustedHeaders: $config->security->trustedHeaders,
        );

        self::application($root, $config)->handle($request)->send();
    }

    public static function application(string $root, Configuration $config): Application
    {
        $session = new SessionManager();
        $tokens = new SessionCsrfTokenManager($session);

        $container = new Container();
        $container->singleton(Greeter::class, fn (): Greeter => new Greeter('Hello'));

        $events = new EventDispatcher();
        $events->addListener(ExceptionEvent::class, function (ExceptionEvent $event): void {
            if (!$event->isRoutingFailure()) {
                error_log($event->getException()::class . ': ' . $event->getException()->getMessage());
            }
        });

        $router = (new Router())->discoverControllers('App\\Controllers', $root . '/app/Controllers');

        return ApplicationBuilder::create()
            ->withConfiguration($config, basePath: $root)
            ->withRouter($router)
            ->withContainer($container)
            ->withEventDispatcher($events)
            ->withExceptionHandler(
                OrderNotFound::class,
                fn (\Throwable $e, Request $request): Response => Response::html('<h1>No such order</h1>', 404),
            )
            ->withMiddleware(new SecureHeadersMiddleware($config->security->headers))
            ->withMiddleware(new MaxBodySizeMiddleware($config->security->maxBodySize))
            ->withMiddleware(new SessionMiddleware($config->session, $session))
            ->withMiddleware(new CsrfMiddleware($tokens, CsrfConfig::fromSecurityConfig($config->security)))
            ->build();
    }
}
// public/index.php
require dirname(__DIR__) . '/vendor/autoload.php';

App\Kernel::run(dirname(__DIR__));

Request::fromGlobals() ne lit pas config.yml : transmettez vous-même les réglages de mandataire, comme ci-dessus. Le fichier .env n’est pas lu non plus, voir Configuration.

Le builder

ApplicationBuilder est immuable : chaque méthode with*() retourne un nouveau builder. Un appel dont vous ignorez le résultat ne monte rien : enchaînez donc les appels ou réassignez ($builder = $builder->withMiddleware(...)). build() retourne l’Application.

Méthode Ce qu’elle câble
withConfiguration($config, basePath: $root) La configuration : la localisation (un locale_path relatif est résolu à partir de basePath) et, avec une section router:, le routeur. build() applique application.debug et localization.timezone
withRouter($router) Le routeur. Sans routeur, et sans section router:, chaque requête reçoit un 404
withMiddleware($m) Un middleware global : il s’exécute à chaque requête, y compris les 404 et 405, le premier ajouté étant le plus à l’extérieur
registerMiddleware('name', $m) Donne à un middleware un nom que les routes peuvent référencer. Il ne s’exécute que sur ces routes
withContainer($container) Construit les contrôleurs au moyen du conteneur
withControllerFactory($callable) Construit les contrôleurs avec votre propre callable(class-string): object
withRenderEngine($engine) Partage un moteur de vues avec tous les contrôleurs qui utilisent RenderResponses
withEventDispatcher($events) Le répartiteur qui reçoit les événements du noyau. Sans lui, aucun événement n’est émis
withExceptionHandler($class, $callable) Associe une classe d’exception à une réponse : voir Erreurs
withDebugClientAllowlist(...), withProductionDebugAcknowledged() Qui peut voir le débogueur : voir Erreurs
withAcknowledgedSecurityKeys([...]), withoutSecurityWiringCheck() La vérification de sécurité au démarrage : voir Sécurité

La localisation a ses propres points d’entrée : withLocalizationConfig(), withJsonLocales($path), withJsonLocaleLayers($paths), withFallbackLoaders($loaders), withLocaleLoader($loader) et withSupportedLocales($locales). Voir Localisation.

Vous pouvez aussi partir d’une configuration au lieu de l’ajouter ensuite : ApplicationBuilder::fromConfiguration($config), fromConfigurationFile($path), fromConfigurationArray($array), fromConfigurationFiles($paths), fromOptionalConfigurationFiles($paths) (les fichiers manquants sont ignorés), ainsi que les variantes buildFrom...() qui retournent directement l’Application. withConfigurationFile(), withConfigurationFiles(), withOptionalConfigurationFiles() et withConfigurationArray() font la même chose sur un builder existant.

fromConfigurationFile($path) et buildFromConfigurationFile($path) passent le chemin réel du répertoire du fichier comme basePath : un localization.locale_path relatif et les chemins de router: se résolvent donc à partir de ce répertoire. La version 0.15.0 ne passait aucun basePath, et un locale_path relatif se résolvait à partir du répertoire courant : écrivez-le relatif au fichier de configuration, ou absolu. Les autres points d’entrée n’en prennent pas : withConfigurationFile() et fromConfigurationFiles() refusent une section router: faute de basePath, avec le correctif dans le message.

Des contrôleurs issus de la configuration

Avec une section router: dans config.yml (voir Routage), le builder construit lui-même le routeur et lit le cache des routes en production et en staging :

$app = ApplicationBuilder::buildFromConfigurationFile($root . '/config.yml');

Appeler withRouter() avec un routeur que Router::fromConfiguration($config, basePath: $root) n’a pas construit est refusé :

Configuration declares router.controllers, but withRouter() received a router that Router::fromConfiguration() did not build, so the routes cache would never be read. Remove the withRouter() call to let build() discover the controllers, or start the router passed to it from Router::fromConfiguration($configuration, basePath: <application root>).

Découvrir les contrôleurs

Sans section router:, Router::discoverControllers($namespace, $directory) enregistre chaque classe du répertoire dont les attributs déclarent une route. L’espace de noms est le préfixe PSR-4 de ce répertoire, et le répertoire est un chemin absolu. Un répertoire inexistant, ou un espace de noms qui ne correspond pas, n’enregistre rien et ne lève aucune erreur : si une route répond 404, vérifiez d’abord ces deux arguments. Voir Routage et Contrôleurs.

Des contrôleurs construits par le conteneur

withContainer() résout chaque contrôleur au moyen d’un conteneur, de sorte que les dépendances du constructeur sont injectées :

use Zephyrus\Controller\Controller;
use Zephyrus\Routing\Attribute\Get;

final class HelloController extends Controller
{
    public function __construct(private readonly Greeter $greeter)
    {
    }

    #[Get('/hello/{name}')]
    public function hello(string $name): Response
    {
        return $this->json(['message' => $this->greeter->greet($name)]);
    }
}

Une classe pour laquelle le conteneur n’a aucune liaison est construite par câblage automatique de son constructeur typé. withControllerFactory() et withContainer() définissent le même point d’accroche : le dernier appelé l’emporte. withRenderEngine() continue de fonctionner avec les deux.

Middleware global ou nommé

Seul un middleware passé à withMiddleware() compte pour la vérification du câblage de sécurité. Un middleware enregistré par nom, ou enveloppé dans un autre middleware, n’est pas pris en compte. Voir Middlewares pour l’ordre d’exécution et les middlewares de route.

Traiter une requête

Application est un objet en lecture seule qui n’a qu’une tâche :

$response = $app->handle(Request::fromArray('GET', '/hello/ada'));

$response->status;           // 200
$response->body;             // {"message":"Hello, ada"}

En production, Kernel::run() se termine par ->send(), qui écrit la ligne de statut, les en-têtes et le corps. Dans un test, vous gardez la Response et vous faites vos assertions dessus : aucun serveur n’est nécessaire.

$app = Kernel::application($root, Configuration::fromYamlFile($root . '/config.yml'));
$response = $app->handle(Request::fromArray('GET', '/hello/ada'));

Request::fromArray() construit une requête à partir de valeurs simples (body:, query:, headers:, cookies:). Request::fromGlobals() en construit une à partir des superglobales PHP, et ne doit se trouver que dans public/index.php.

Application expose aussi le traducteur : trans($key, $parameters, $locale), transFromRequest(...) et resolveLocaleFromRequest(...). Voir Localisation.

Le registre App

build() enregistre les services que lisent des fonctions utilitaires comme config(), route(), localize() et format() : la configuration, le traducteur, le formateur et le générateur d’URL. Ils vivent dans des propriétés statiques de Zephyrus\Core\App (App::getConfiguration(), getTranslator(), getFormatter(), getUrlGenerator(), getSession(), getAsset(), et les mutateurs correspondants), ainsi que App::nonce() pour le nonce de la Content-Security-Policy.

Cela suppose une seule requête par processus, comme le font php-fpm et mod_php. Les processus persistants (RoadRunner, Swoole, le mode worker de FrankenPHP) ne sont pas pris en charge : App::reset() vide le registre pour les tests, ce n’est pas un point d’accroche entre deux requêtes.

Un script en ligne de commande qui n’appelle jamais build() doit définir lui-même le formateur, sinon format('decimal', 1234.5) lève une exception :

App::setFormatter(new Formatter('en_CA'));

Le cycle de vie d’une requête

HttpKernel traite chaque requête dans le même ordre :

  1. Un RequestEvent est émis. Un écouteur peut répondre de lui-même avec setResponse(), ce qui saute le routage.
  2. Le routeur fait correspondre la requête. Les paramètres de la route sont connus avant l’exécution de tout middleware.
  3. Les middlewares globaux s’exécutent, du plus externe au plus interne, autour de l’action de contrôleur trouvée. Pour un chemin inconnu ou une méthode incorrecte, ils s’exécutent plutôt autour de la réponse 404 ou 405.
  4. Le contrôleur s’exécute (les middlewares de route l’enveloppent).
  5. Un ResponseEvent est émis. Un écouteur peut remplacer la réponse.

Lorsque quelque chose lève une exception à l’étape 2, 3 ou 4, le noyau émet un ExceptionEvent, puis convertit l’exception en réponse (voir Erreurs). La réponse d’erreur traverse tout de même les middlewares globaux : elle porte donc vos en-têtes de sécurité.

Trois réponses contournent les middlewares globaux : un court-circuit par RequestEvent, la réponse de secours utilisée lorsqu’un middleware global lève lui-même une exception, et une réponse remplacée par un écouteur de ResponseEvent. Un en-tête qui doit atteindre chaque réponse a aussi sa place dans le serveur web ou le mandataire.

Les événements sont décrits dans Conteneur et événements.

La vérification du câblage de sécurité

Déclarer une clé sous security: est une promesse : build() refuse de démarrer si aucun middleware global ne l’applique. Voir Sécurité pour le tableau des clés et le middleware dont chacune a besoin.

Configuration declares security settings that this application does not enforce as declared:
  - security.csrf is not enforced as declared: mount new Zephyrus\Security\CsrfMiddleware(...) globally with withMiddleware(), ...

Bon à savoir

  • Le premier middleware ajouté est le plus à l’extérieur. Placez SecureHeadersMiddleware en premier.
  • build() définit le fuseau horaire par défaut et active ou désactive le débogueur. Les deux valent pour tout le processus.
  • withRenderEngine() retourne un nouveau builder comme toutes les autres méthodes : $builder->withRenderEngine($views); seul ne fait rien.
  • Les middlewares globaux s’exécutent aussi sur les réponses 404 et 405 : leurs effets de bord (démarrer une session, par exemple) s’y produisent donc aussi. Une route peut se soustraire à un middleware global avec #[WithoutMiddleware].
↑ ↓ PARCOURIR   ↵ OUVRIRLE GUIDE ZEPHYRUS