Core concepts

Core concepts

What exactly Niang\Core\Application does between the HTTP request and the response it sends, how the Container resolves your dependencies, and where to hook code in at boot time.

Request lifecycle

public/index.php creates an Application, loads the routes, then calls run(). The Application constructor does, in order:

  1. Loads .env (or .env.testing when APP_ENV=testing) and config/*.php.
  2. Creates the Container and the Router.
  3. Registers in the Container, as singletons: Router, Container, Psr\Container\ContainerInterface, the Application itself, Logger and Psr\Log\LoggerInterface.
  4. Configures error_reporting / display_errors according to APP_DEBUG.
  5. Instantiates every Service Provider listed in config('app.providers'), calls register() on all of them, then boot() on all of them — never register() then boot() one provider at a time (see Service Providers).

run() then handle() do the rest:

packages/foundation/src/Application.php
public function run(): void
{
    Session::start();
    $this->handle(Request::capture())->send();
}

public function handle(Request $request): Response
{
    $startedAt = hrtime(true);
    DB::resetQueryCount();

    try {
        $response = $this->router->dispatch($request, $this->container);
    } catch (\Throwable $e) {
        $response = Handler::render($e, $request, $startedAt);
    }

    $response = $this->applySecurityHeaders($response);
    $response = DebugToolbar::inject($response, $startedAt);

    return $this->compressIfSupported($response, $request);
}

Which gives, in one sentence, the full path of a request:

public/index.php → Application → Router::dispatch() → route middleware (in order, as a pipeline) → Controller → Response, then applySecurityHeaders() (headers from config/security.php), DebugToolbar::inject() and conditional gzip compression before sending.

Any exception thrown during dispatch — including an HttpException coming from abort() or a validation exception — is caught exactly once here and turned into a response by Handler::render(); the router itself does not “know” how to display an error page.

Framework events

For an extension or package that must act on every request without touching the application code:

EventWhen
Events\ApplicationBootedafter every Service Provider's boot()
Events\RequestReceivedstart of handle(), before maintenance and routing
Events\RouteMatchedroute found, before middleware
Events\ResponsePreparedresponse ready, just before sending (still editable)
Events\RequestTerminatedafter sending (with PHP-FPM, the visitor no longer waits)
php
Event::listen(ResponsePrepared::class, fn (ResponsePrepared $e) => $e->response->header('X-Version', '2.1'));

Only fired when listened to: no cost otherwise.

The Container

Niang\Core\Container implements Psr\Container\ContainerInterface (get()/has()) and adds two registration methods:

bind() and singleton()

php
// bind(): a closure called on every resolution; it receives the container.
$container->bind(PaymentGateway::class, fn ($c) => new StripeGateway($c->make(Config::class)));

// singleton(): an already-built instance, returned as-is on every call.
$container->singleton(Logger::class, new Logger());

Without an explicit bind(), make(SomeClass::class) instantiates SomeClass directly through reflection (see below) — a binding is only needed to pick a concrete implementation behind an interface, or to build an object that needs arguments that cannot be resolved automatically.

Classes, lazy singletons and contextual bindings

php
$container->bind(PaymentGateway::class, StripeGateway::class);        // interface -> class
$container->singleton(CacheManager::class);                           // one instance, created on first use
$container->instance(Clock::class, new FrozenClock('2026-01-01'));     // already built object
$container->when(RefundController::class)->needs(PaymentGateway::class)->give(PaypalGateway::class);

bind() creates a new instance on each resolution; singleton() and instance() always return the same one. A contextual binding picks an implementation depending on the class asking for it, for the constructor as well as method injection.

Reflection-based injection

make() and call() inspect the parameters of a constructor or a method with ReflectionParameter and resolve each of them, in this order:

  1. A declared, non-builtin type (a class/interface): looked up among the values already known for that type (e.g. the current Request), otherwise resolved recursively through make(). A FormRequest is a special case: built from the current request then validated (see Container::makeFormRequest()), never instantiated empty.
  2. A named value provided explicitly (e.g. the route parameters passed to call()).
  3. A route parameter with the same name as the method parameter (function show($id) directly receives the {id} from the URL).
  4. The parameter's default value, if there is one.
  5. null, if the type allows it.
  6. Otherwise, a ContainerException — with the parameter's name, its type, and the context (class/method) in the message.

This mechanism is what lets a controller declare index(Request $request) or show(string $id) without any configuration: the Router calls every action through $container->call([$controller, $method], ['request' => $request]).

A circular dependency (A needs B, which needs A) is detected before infinite recursion and throws a ContainerException listing the full chain. Resolving an interface or an abstract class without a prior bind() fails with a message reminding you how to register it.

Service Providers

A Service Provider extends Niang\Core\ServiceProvider and overrides register() (bindings in the Container) and/or boot() (event listeners, initialization — run once every provider has finished its register(), so one provider can rely on a binding set up by another):

app/Providers/AppServiceProvider.php

namespace App\Providers;

use Niang\Core\Event;
use Niang\Core\Log;
use Niang\Core\ServiceProvider;

class AppServiceProvider extends ServiceProvider
{
    public function boot(): void
    {
        Event::listen('user.registered', function (array $user): void {
            Log::info('Nouvel utilisateur inscrit : {email}', ['email' => $user['email']]);
        });
    }
}

It is declared in config/app.php, which lists every provider of the project:

config/app.php
'providers' => [
    App\Providers\AppServiceProvider::class,
],

Extension points

The framework's components do not know each other directly: Log knows nothing about traces, Cache nothing about multi-tenancy, Event nothing about the queue. Each exposes an extension point, and Application::wire() connects them at startup (ADR 0012) — which is what will let the framework be split into independent packages. An application can use them too, for example in a Service Provider's boot():

php
// Add the user's ID to every log line
Log::contextUsing('user', fn (): array => ['user_id' => Auth::id()]);

// A header on every outgoing call (webhooks, OAuth, S3...)
Http\Client::headersUsing('version', fn (): array => ['X-App-Version' => '2.3.0']);

// Record the current language on every job, and restore it in the worker
Queue::stampUsing('language', fn (Job $job) => $job->context['locale'] = Lang::locale());
Queue::wrapUsing('language', function (Job $job, Closure $next): void {
    Lang::setLocale($job->context['locale'] ?? 'en');
    $next();
});
Extension pointPurposeConnected by the framework to
Log::contextUsing($name, $provider)context added to every messagethe request's request_id
Http\Client::headersUsing($name, $provider)headers of outgoing callstraceparent
Event::queueUsing($queue)deferring a ShouldQueue listenerQueue (no queue: runs at once)
Cache::prefixUsing($prefix)prefix of every keycurrent tenant
Model::tenantScopeUsing($scope)filter of $tenantScoped modelsTenancy
Metrics::isolateUsing($wrapper)context of metric reads and writesoutside any tenant
Queue::stampUsing(), wrapUsing(), afterUsing()when queued, around execution, after every joboriginating request and tenant, metrics
Container::resolveUsing($baseClass, $resolver)building a whole family of classesFormRequest (by the Router)

Each named extension point replaces a provider with the same name: a second call does not add to the first.

⏱ 8.87 ms 🗄 0 requête(s) SQL 🧠 4.00 MB ↩ 200