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:
- Loads
.env(or.env.testingwhenAPP_ENV=testing) andconfig/*.php. - Creates the
Containerand theRouter. - Registers in the Container, as singletons:
Router,Container,Psr\Container\ContainerInterface, theApplicationitself,LoggerandPsr\Log\LoggerInterface. - Configures
error_reporting/display_errorsaccording toAPP_DEBUG. - Instantiates every Service Provider listed in
config('app.providers'), callsregister()on all of them, thenboot()on all of them — neverregister()thenboot()one provider at a time (see Service Providers).
run() then handle() do the rest:
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:
| Event | When |
|---|---|
Events\ApplicationBooted | after every Service Provider's boot() |
Events\RequestReceived | start of handle(), before maintenance and routing |
Events\RouteMatched | route found, before middleware |
Events\ResponsePrepared | response ready, just before sending (still editable) |
Events\RequestTerminated | after sending (with PHP-FPM, the visitor no longer waits) |
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()
// 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
$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:
- 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 throughmake(). AFormRequestis a special case: built from the current request then validated (seeContainer::makeFormRequest()), never instantiated empty. - A named value provided explicitly (e.g. the route parameters passed to
call()). - A route parameter with the same name as the method parameter (
function show($id)directly receives the{id}from the URL). - The parameter's default value, if there is one.
null, if the type allows it.- 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):
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:
'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():
// 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 point | Purpose | Connected by the framework to |
|---|---|---|
Log::contextUsing($name, $provider) | context added to every message | the request's request_id |
Http\Client::headersUsing($name, $provider) | headers of outgoing calls | traceparent |
Event::queueUsing($queue) | deferring a ShouldQueue listener | Queue (no queue: runs at once) |
Cache::prefixUsing($prefix) | prefix of every key | current tenant |
Model::tenantScopeUsing($scope) | filter of $tenantScoped models | Tenancy |
Metrics::isolateUsing($wrapper) | context of metric reads and writes | outside any tenant |
Queue::stampUsing(), wrapUsing(), afterUsing() | when queued, around execution, after every job | originating request and tenant, metrics |
Container::resolveUsing($baseClass, $resolver) | building a whole family of classes | FormRequest (by the Router) |
Each named extension point replaces a provider with the same name: a second call does not add to the first.