PHPackages                             meulah/framework - PHPackages - PHPackages  [Skip to content](#main-content)[PHPackages](/)[Directory](/)[Categories](/categories)[Trending](/trending)[Leaderboard](/leaderboard)[Changelog](/changelog)[Analyze](/analyze)[Collections](/collections)[Log in](/login)[Sign up](/register)

1. [Directory](/)
2. /
3. meulah/framework

ActiveLibrary

meulah/framework
================

A small, explicit PHP framework for conventional server-rendered applications.

v0.1.0(2d ago)001MITPHPPHP &gt;=8.1CI passing

Since Jul 22Pushed today1 watchersCompare

[ Source](https://github.com/Meulah/framework)[ Packagist](https://packagist.org/packages/meulah/framework)[ RSS](/packages/meulah-framework/feed)WikiDiscussions main Synced today

READMEChangelog (2)DependenciesVersions (2)Used By (0)

Meulah
======

[](#meulah)

Meulah is a small, explicit PHP framework for conventional server-rendered applications. It keeps the request lifecycle visible and uses modern PHP where it improves safety and clarity.

Philosophy
----------

[](#philosophy)

- Small enough to understand completely.
- Explicit over magical.
- Secure defaults belong in the framework.
- Plain PHP views and direct PDO access remain first-class choices.
- Application features stay separate from the reusable kernel.
- Existing Meulah applications should have a practical upgrade path.

Meulah currently requires PHP 8.1 or newer.

Package boundary
----------------

[](#package-boundary)

This repository contains only the reusable framework package:

```
src/       reusable framework code (meulah/framework)
bin/       executable installed as vendor/bin/meulah
tests/     framework contract tests

```

The [Meulah application starter](https://github.com/Meulah/meulah) is maintained in its own repository. It owns application concerns: the `App\` namespace, environment file, configuration, bootstrap, routes, controllers, views, migrations, public entry point, and root `meulah` launcher. The framework root is a Composer library and is not itself a web application.

Request lifecycle
-----------------

[](#request-lifecycle)

Inside an application created from the starter, the request lifecycle remains visible:

```
public/index.php
  -> bootstrap.php
  -> Application
  -> routes/web.php
  -> Router
  -> route handler

```

Routes are declared explicitly in `routes/web.php`:

```
use Meulah\Http\Response;

$router->get('/', static fn (): Response => Response::html('Hello'), 'home');
```

The router provides `get()`, `post()`, `put()`, `patch()`, `delete()`, and `options()` for ordinary HTTP routes. Use `match()` when one handler intentionally accepts a specific set of methods. Route methods must be valid HTTP token strings; empty values, control characters, and header-like method text are rejected when routes are registered. Route paths cannot contain control characters, query strings, or fragments.

Route registration is deterministic. A second registration with an overlapping method and the same normalized path throws `RouteDefinitionException`; GET registrations include HEAD for this check. Duplicate names also fail instead of replacing the earlier route. When both a static and dynamic route match the same method, static routes are evaluated first regardless of declaration order. Routes of the same kind retain declaration order.

One trailing slash is normalized away, including across nested group boundaries. Repeated internal slashes remain distinct. Request paths are percent-decoded exactly once before routing, so an encoded slash becomes a path separator; encode a literal percent sign when `%2F` must remain segment data. `Request::capture()` removes the installed application subdirectory before dispatch.

Related routes can share a path prefix, name prefix, and middleware. Groups may be nested; parent attributes are applied before child attributes:

```
$router->group([
    'prefix' => '/admin',
    'name' => 'admin.',
    'middleware' => [$auth, $admin],
], function (Router $router): void {
    $router->get('/users', [UserController::class, 'index'], 'users.index');
});

// Path: /admin/users
// Name: admin.users.index
// Middleware: auth, then admin
```

Constrain dynamic path segments with regular-expression fragments. A constraint mismatch behaves like any other non-matching route:

```
$router
    ->get('/users/{user}', [UserController::class, 'show'], 'users.show')
    ->where('user', '\d+');
```

Constraints only select routes and pass the matched string to the handler. Meulah does not perform implicit route-model binding. Constraint fragments are validated when registered and matched against the entire decoded path segment with `\A` and `\z` anchors. Ordinary capture groups are allowed, but named captures are rejected because they can collide with Meulah's internal route-parameter captures. Meulah selects a delimiter absent from the fragment rather than rewriting user regular expressions.

The optional final argument names a route. Generate root-relative URLs through the router rather than hard-coding application paths:

```
$router->get('/users/{user}', [UserController::class, 'show'], 'users.show');

$url = $router->url(
    'users.show',
    ['user' => 42],
    ['tab' => 'profile'],
);
// /users/42?tab=profile
```

Path parameters are required by name and encoded as URL segments. A single parameter cannot contain `/` or `\`; model multi-segment paths as separate route parameters. The separate query array supports nested values and uses RFC 3986 encoding. Unknown route names, missing or extra path parameters, empty values, and duplicate route names fail immediately with a clear exception. Generated URLs are deliberately root-relative; applications that need an absolute URL should prepend their explicitly configured trusted origin.

Unknown paths return `404 Not Found`. A known path requested with an unsupported HTTP method returns `405 Method Not Allowed` with a stable, de-duplicated `Allow` header. GET always adds HEAD, and HEAD responses have their body removed. OPTIONS is explicit: register an `options()` route when an endpoint handles it; otherwise a matching path returns 405 and advertises its registered methods.

Controller array handlers and invokable class strings must resolve to public callables. The first parameter must be typed exactly as `Request` to receive request injection. Decoded route parameters may target untyped, `string`, or `mixed` handler parameters; scalar coercion, union/intersection guessing, and by-reference request or route arguments are rejected with `RouteHandlerException` context.

`Meulah\Http\Response` is the default implementation of `ResponseInterface`. Routing, middleware, request handlers, and the application kernel depend on the interface, so applications may return another compatible response implementation when needed.

Dependency injection
--------------------

[](#dependency-injection)

Controller class handlers are constructed through the application's dependency container. Constructor parameters typed as concrete classes are recursively autowired; interfaces require an explicit binding:

```
use Meulah\Config\Repository;
use Meulah\Container\Container;

$container = $app->container();
$container->bind(UserRepository::class, PdoUserRepository::class);
$container->singleton(Cache::class, function (Container $container): Cache {
    return new Cache($container->get(Repository::class));
});
$container->instance(Clock::class, $clock);
```

`bind()` creates a new instance for each resolution, `singleton()` reuses the first resolved instance, and `instance()` registers an existing object. Factories receive the container and must return an object.

Controllers can then declare their dependencies normally:

```
use Meulah\Http\Request;

final class UserController
{
    public function __construct(private readonly UserRepository $users)
    {
    }

    public function show(Request $request, string $user): string
    {
        return $this->users->find($user)->name;
    }
}

$router->get('/users/{user}', [UserController::class, 'show']);
```

Invokable controller class strings are also supported. Meulah deliberately does not guess scalar values, choose among union types, or invent implementations for interfaces. Register those decisions explicitly; unresolved and circular dependencies produce `BindingResolutionException` with the dependency context.

Request data
------------

[](#request-data)

Type the first route-handler parameter as `Request` to receive the current request. Route parameters follow it:

```
use Meulah\Http\Request;

$router->post('/users/{user}', function (Request $request, string $user): string {
    $trace = $request->header('x-request-id');
    $name = $request->input('name');

    return "Updated {$user}";
});
```

For server-rendered forms, a POST request may explicitly represent `PUT`, `PATCH`, or `DELETE` through a hidden `_method` field:

```

```

The `X-HTTP-Method-Override` header is also supported for clients that cannot send those methods directly. Overrides are considered only when the original method is `POST`, and only `PUT`, `PATCH`, or `DELETE` are accepted. Query-string overrides are ignored. Regular ASCII spaces around a string value are allowed; controls, empty values, arrays, objects, comma-separated method lists, and duplicate override headers produce `400 invalid_method_override`.

When both the form field and header are present, their normalized values must match. Matching values are accepted deliberately; conflicts produce `400`. Overrides on GET or any other original method are ignored, even when an `_method` field is present. The effective method is resolved while constructing the request, before CSRF middleware and routing. Use `originalMethod()` for the transport method and `method()` for the effective method. Original request methods must also be valid HTTP tokens. Invalid method text, non-string server path metadata, malformed `Content-Length` values, and control characters in decoded request paths produce a `400` response before routing.

Request data remains explicit:

```
$request->header('authorization'); // case-insensitive
$request->headers();
$request->hasHeader('authorization');
$request->query('page');
$request->form('name');
$request->json('email');
$request->json('profile.name');
$request->jsonValue();
$request->jsonObject();
$request->jsonArray();
$request->cookie('session');
$request->file('avatar');
$request->files();
$request->rawBody();
$request->input('name');
$request->allInput();
$request->hasInput('name');
$request->filled('name');
$request->hasFile('avatar');
```

`input()` contains ordinary values only—uploaded files are never merged into it. For form requests, form values override query values. For JSON requests, object fields override query values and form data is ignored. The selected body representation is therefore used first, with query parameters as fallback.

JSON is recognized for `application/json` and `+json` content types. `jsonValue()` and `json()` without a key return the exact decoded shape: objects remain `stdClass`, arrays remain arrays, and scalars remain scalars. `jsonObject()` and `jsonArray()` enforce the expected top-level shape with a `400` error. Keyed and dotted `json()` lookup applies only to top-level JSON objects and otherwise returns the supplied default. An empty JSON body is deliberately treated as an empty object.

Typed input access is strict:

```
$request->string('name');
$request->integer('page', default: 1);
$request->boolean('published', default: false);
$request->array('roles');
```

Defaults apply only when input is missing. A supplied value of the wrong type produces an `invalid_input` 400 error instead of silently coercing to `0`, `false`, or an empty value. Request data has no merge or replace methods: it continues to represent the original client request, while validation and normalization should produce separate application data.

The raw request body is read once, cached on the request, and shared by `rawBody()` and `json()`. `HTTP_MAX_BODY_SIZE` defaults to 10 MiB and limits how much data Meulah reads into memory; web-server body limits should also remain enabled. A request-body read failure is reported as `request_body_unavailable`; it is never silently treated as an empty body.

Nested PHP uploads are normalized into `UploadedFile` objects while preserving their original keys. An uploaded file exposes `clientFilename()`, `clientMediaType()`, server-inspected `detectedMediaType()`, `temporaryPath()`, `error()`, and `size()`. Client filenames and client media types are untrusted input. Applications should generate storage names and validate detected type and file contents independently.

Call `isValid()` before `moveTo($destination)`. The destination must be a writable file path inside an existing directory, existing files are never overwritten, and a file cannot be moved twice. `hasMoved()` and `movedPath()` expose its lifecycle. Production uploads require `is_uploaded_file()` and `move_uploaded_file()`; `UploadedFile::forTesting()` provides an explicit filesystem-backed test double without weakening production checks.

Headers and cookies are raw, untrusted client input. In particular, Meulah does not trust `Forwarded` or `X-Forwarded-*` headers automatically, and retrieving a cookie does not validate, decrypt, or turn it into session state.

For requests that expect JSON, malformed bodies and other request errors use a stable machine-readable shape:

```
{
  "error": {
    "code": "invalid_json",
    "message": "The request body contains malformed JSON."
  }
}
```

Parser detail is omitted in production and included only when development debugging is enabled.

Response cookies and sessions
-----------------------------

[](#response-cookies-and-sessions)

Create response cookies through the immutable `Cookie` value object:

```
use Meulah\Http\Cookie;
use Meulah\Http\SameSite;

$cookie = Cookie::make(
    name: 'theme',
    value: 'dark',
    expires: new DateTimeImmutable('+30 days'),
    path: '/',
    secure: true,
    httpOnly: true,
    sameSite: SameSite::Lax,
    domain: 'example.com',
    maxAge: 2_592_000,
);

return Response::html('Saved')->withCookie($cookie);
```

`withCookie()` returns a new response. Multiple calls retain multiple cookies, including duplicate names with different paths, and `send()` emits each one as a separate `Set-Cookie` header without replacing unrelated `Set-Cookie` headers. Cookie values are percent-encoded, so Unicode and reserved value characters are serialized rather than copied into the header. Raw control characters are rejected.

Cookie names must use HTTP token syntax. Paths must begin with `/` and use visible ASCII without spaces or semicolons; percent-encode non-ASCII URL paths before using them. Domains are optional, ASCII-only host names; convert internationalized names to punycode before passing them. Negative `Max-Age`, unsupported SameSite values, `SameSite=None` without `Secure`, invalid `__Secure-` or `__Host-` attributes, and expiration years outside 1601 through 9999 are rejected. Use `Cookie::forget('session', path: '/', domain: 'example.com')` to produce an explicit `Expires` plus `Max-Age=0` deletion cookie. `send()` fails explicitly if PHP has already committed response headers.

Sessions remain an explicit application choice. Bind the contract to the native PHP driver in application bootstrap when session state is needed:

```
use Meulah\Http\SameSite;
use Meulah\Session\NativeSession;
use Meulah\Session\Session;
use Meulah\Session\SessionMiddleware;

$secureCookies = $app->config()->bool('session.secure', true);

$container->singleton(
    Session::class,
    static fn () use ($secureCookies): Session => new NativeSession(
        name: 'MEULAHSESSID',
        secure: $secureCookies,
        httpOnly: true,
        sameSite: SameSite::Lax,
    ),
);

$session = $container->get(Session::class);
$app->middleware(new SessionMiddleware($session));
```

`NativeSession` starts lazily on the first session operation; `SessionMiddleware` does not force a start and always closes the session after downstream handling, including exceptions. Startup enables strict session IDs, cookie-only transport, disabled URL ID propagation, explicit cookie attributes, and validation of attacker-controlled identifiers. Unknown IDs are rejected by PHP strict mode. Invalid ID syntax is discarded without exposing native warnings.

Bind one `NativeSession` instance per application request when possible. Two wrappers cannot manage the same active session, and unmanaged active native sessions are rejected. On `close()`, Meulah writes the session and clears `$_SESSION` plus the process-local session ID, preventing a retained object in a long-running worker from inheriting the previous request's state. The next start selects the incoming session cookie explicitly.

```
$user = $session->get('user');
$session->put('user', $user);
$session->remove('temporary');
$session->regenerate();
$session->invalidate();
$session->flash('notice', 'Profile saved.');
$session->keep('notice');
$session->reflash();
```

`regenerate()` rotates the identifier while preserving ordinary and flash data. Call it immediately after authentication or another authentication-sensitive privilege change to prevent fixation. `invalidate()` clears all data, rotates the identifier, and emits an explicit deletion cookie for the browser. Both operations fail before invoking PHP when response output has already started.

Flash lifecycle is request-based and deterministic:

- `flash()` creates new flash data that is readable immediately and during the next request.
- At the start of that next request it becomes old flash data.
- At the start of the following request it is removed unless `keep()` retained selected keys or `reflash()` retained all old keys.
- Writing an ordinary value with `put()` removes that key's flash marker. Repeated `flash()` calls overwrite the value without duplicating lifecycle metadata.

Secure cookies remain the default. Production should use HTTPS, `secure: true`, `httpOnly: true`, `SameSite::Lax` or `SameSite::Strict`, and should omit `domain` unless subdomain sharing is required. For local plain-HTTP development, set an explicit local configuration value such as `session.secure => false`; Meulah never weakens Secure automatically because the host looks local. `SameSite::None` always requires Secure.

Only the native PHP driver exists in this milestone. Its persistence is controlled by PHP's configured session save handler. File, database, and Redis drivers remain future adapters behind the same `Session` contract.

Authentication contracts
------------------------

[](#authentication-contracts)

Meulah provides model-agnostic authentication contracts and one session-backed guard. It does not provide a User model, table layout, credential fields, password hashing workflow, or ORM-backed provider. See the [complete authentication and authorization integration guide](docs/authentication.md) for an application User, credential provider, request-scoped container setup, login and logout actions, middleware, Gate definitions, CSRF wiring, worker lifecycle rules, and intentionally omitted features.

An application identity implements only `Authenticatable`:

```
use Meulah\Auth\Authenticatable;

final class User implements Authenticatable
{
    public function __construct(
        public readonly int $id,
        public readonly string $email,
    ) {
    }

    public function authIdentifier(): string
    {
        return (string) $this->id;
    }
}
```

Authentication identifiers are deliberately non-empty strings. Session serialization therefore preserves identifiers such as `"0"`, `"00042"`, UUIDs, and Unicode identifiers without guessing an integer width or silently changing representation. Applications with integer primary keys cast them explicitly at their boundary. Meulah does not trim or otherwise normalize identifiers.

The first `UserProvider` contract restores only by that stable identifier:

```
use Meulah\Auth\Authenticatable;
use Meulah\Auth\UserProvider;

final class ApplicationUserProvider implements UserProvider
{
    public function retrieveById(string $identifier): ?Authenticatable
    {
        // Query an application repository and return its User, or null.
    }
}
```

Meulah does not provide an ORM-backed implementation because table names, key types, tenancy, soft deletion, persistence libraries, and identity lifecycles are application policy. The contract works equally with an ORM, PDO-backed repository, remote service, or in-memory fake.

Credential lookup and password verification do not belong in this contract yet. Adding `retrieveByCredentials()` would prematurely define credential-field and secret-handling conventions before an `attempt()` workflow exists. Applications remain free to build an explicit login service around their own repository and password hasher, then pass the verified identity to `Guard::login()`.

Bind the application provider, the request's session, and the guard explicitly. The authentication session key is required configuration:

```
use Meulah\Auth\Guard;
use Meulah\Auth\SessionGuard;
use Meulah\Auth\UserProvider;
use Meulah\Session\Session;
use Meulah\Container\Container;

$container = $app->container();
$container->instance(Session::class, $session);
$container->bind(UserProvider::class, ApplicationUserProvider::class);
$container->bind(
    Guard::class,
    static fn (Container $container): Guard => new SessionGuard(
        $container->get(Session::class),
        $container->get(UserProvider::class),
        '_auth_user',
    ),
);

$guard = $container->get(Guard::class);
```

The configured key must be non-empty and should be reserved for the guard. It lets applications avoid collisions between session-backed components without imposing a framework-global key.

`SessionGuard` stores only the string identifier, never the entire user object. `user()` restores lazily through the provider and caches the result in that guard object. `id()` uses the same resolution on first access so it cannot report a deleted user as authenticated merely to avoid hydration; subsequent calls use the request-local cache. Invalid stored identifiers or a provider returning a different identifier throw `InvalidAuthenticatableException` without exposing either value. Provider exceptions propagate unchanged.

A provider returning `null` is ordinary guest state: `user()` and `id()` return `null`, `check()` returns `false`, and `guest()` returns `true`. The stale identifier remains in the session deliberately. Reads therefore do not mutate authentication state, and a temporary provider inconsistency does not silently sign the browser out. Applications may call `logout()` when they want to remove it.

`login()` validates the identifier, clears stale in-memory resolution, rotates the session ID, then stores the identifier. Rotation preserves intended session and flash data. Repeated login and switching users rotate the identifier every time.

`logout()` clears cached identity, removes only the configured authentication key, and rotates the session ID. It does not invalidate the whole session or destroy unrelated data. Because Meulah's CSRF token is bound to the session ID, both login and logout make the previous token stale; the next CSRF token access creates a replacement. If an application needs to destroy all session data, it explicitly calls `Session::invalidate()` in its logout workflow.

`SessionGuard` is transient and intended to live for one request. Meulah's container has singleton and transient bindings but no request scope, and an `Application` can survive multiple requests in a long-running worker. A singleton is safe only when the application and container are guaranteed to be rebuilt for every request. Long-running workers must rebuild the complete request graph: container, session, guard, gate, router, and the route middleware instances that capture them. Rebinding only `Guard` is insufficient because an existing router retains its registered middleware objects. The guard cache is tied to the current session ID as a defensive backstop, but it is not a replacement for request scoping when one browser legitimately keeps the same session ID across requests.

### Authentication middleware

[](#authentication-middleware)

`RequireAuthentication` allows requests only when the guard resolves a valid user. The application must provide the rejection response; Meulah does not guess a route name, login URL, or response format:

```
use Meulah\Auth\RequireAuthentication;
use Meulah\Http\Request;
use Meulah\Http\Response;
use Meulah\Http\ResponseInterface;
use Meulah\Routing\Router;

$requireAuthentication = new RequireAuthentication(
    guard: $guard,
    unauthenticated: static function (Request $request): ResponseInterface {
        return Response::redirect('/login');
    },
);

$router->group([
    'middleware' => [$requireAuthentication],
], function (Router $router): void {
    $router->get('/account', [AccountController::class, 'show']);
});
```

APIs should create a separate instance with an explicit 401 response. The middleware never infers this choice from `Accept`, `Content-Type`, or route names:

```
$requireApiAuthentication = new RequireAuthentication(
    guard: $guard,
    unauthenticated: static fn (): ResponseInterface => Response::json(
        ['error' => ['code' => 'unauthenticated']],
        401,
    ),
);
```

`RequireGuest` is the inverse for login, registration, or similar guest-only application routes. Its authenticated response is also application policy:

```
use Meulah\Auth\RequireGuest;

$requireGuest = new RequireGuest(
    guard: $guard,
    authenticated: static fn (): ResponseInterface => Response::redirect('/account'),
);

$router->get('/login', [LoginController::class, 'show'])
    ->middleware($requireGuest);
```

Both classes call `Guard::check()` once, short-circuit before the controller when rejected, and require callbacks to return `ResponseInterface`. They do not log in, log out, mutate request input, attach request attributes, catch provider failures, or expose the user globally. A stale session identifier whose provider record no longer exists follows the unauthenticated path and retains the guard's documented non-mutating stale-identifier policy.

For session-backed web routes, register middleware in this order:

1. `SessionMiddleware` globally and outermost, so authentication can start the session lazily and the session always closes afterward.
2. `VerifyCsrfToken` globally inside the session boundary, so every unsafe session-backed form, including guest forms, is checked.
3. `RequireAuthentication` or `RequireGuest` on route groups or individual routes, before controllers and other route middleware that assumes an authenticated or guest identity.

If CSRF is intentionally route-scoped instead, keep `SessionMiddleware` outside both and declare authentication before CSRF when the application wants rejected guests to receive the authentication response first. Middleware declaration order is execution order on the way into the handler and reverses while responses unwind.

No authentication manager is included because Meulah currently has one guard type and no demonstrated named-guard requirement. Also postponed are `attempt()`, credential validation, unauthenticated HTTP exceptions, login or registration controllers, remember-me cookies, API tokens, OAuth, and JWT.

Password hashing
----------------

[](#password-hashing)

`PasswordHasher` provides only hashing, verification, and rehash checks. It does not retrieve users, validate credentials, persist hashes, or define a login workflow.

Register the immutable native implementation as a singleton:

```
use Meulah\Auth\NativePasswordHasher;
use Meulah\Auth\PasswordHasher;

$container->singleton(
    PasswordHasher::class,
    static fn (): PasswordHasher => new NativePasswordHasher(
        PASSWORD_DEFAULT,
    ),
);
```

The singleton is safe because `NativePasswordHasher` retains only immutable algorithm configuration. It never stores plaintext, hashes, users, or request state.

`PASSWORD_DEFAULT` is always supported. `PASSWORD_ARGON2ID` is supported only when the PHP build defines it and lists it in `password_algos()`. No fallback algorithm is selected silently, and Argon2i is not accepted for new hashes. Verification can still recognize a native hash created with another installed algorithm, allowing an application to verify it and then use `needsRehash()` to migrate it.

Explicit options are strict and algorithm-specific:

- The current bcrypt-backed default accepts an integer `cost` from 4 through 31.
- Argon2id accepts integer `memory_cost` of at least 8, `time_cost` of at least 1, and `threads` of at least 1.
- Unknown options, numeric option keys, stringified integers, and manual `salt` values are rejected. PHP may enforce additional platform and resource limits when hashing.

The hasher does not trim, normalize, lowercase, or otherwise modify plaintext. Empty, Unicode, whitespace-containing, and long passwords are passed exactly to PHP's native password API. Password presence, length, and complexity are application validation policy, not hashing policy. Applications using bcrypt should account for bcrypt's native input-length behavior when defining that policy.

An empty or malformed stored hash never verifies and always needs rehashing. Hashing failures use the generic `PasswordHashingException` message and never include the plaintext. Meulah does not add manual salts; PHP generates a secure random salt for every hash.

PHP's `SensitiveParameter` attribute is not used because Meulah maintains a PHP 8.1 baseline where that attribute is unavailable. This does not change the security contract: applications must never log plaintext arguments, and Meulah's exceptions do not expose them.

Authorization
-------------

[](#authorization)

Meulah authorization is a small gate, not a role or permission system. Applications define exact named abilities and decide them against the current guard user plus explicit arguments. The framework does not infer ownership, query persistence, or grant administrators a hidden bypass.

Register the gate with application abilities in a transient container factory:

```
use Meulah\Auth\Authenticatable;
use Meulah\Auth\Guard;
use Meulah\Authorization\AuthorizationGate;
use Meulah\Authorization\AuthorizationResult;
use Meulah\Authorization\Gate;
use Meulah\Container\Container;

$container->bind(
    Gate::class,
    static function (Container $container): Gate {
        $gate = new AuthorizationGate(
            $container->get(Guard::class),
            $container,
        );

        $gate->define(
            'record.update',
            static function (
                Authenticatable $actor,
                Record $record,
            ): AuthorizationResult {
                return $actor->authIdentifier() === $record->ownerIdentifier
                    ? AuthorizationResult::allow()
                    : AuthorizationResult::deny(
                        'You cannot update this record.',
                        'not_owner',
                    );
            },
        );
        $gate->define('record.archive', ArchiveRecordAbility::class);

        return $gate;
    },
);
```

Use `allows()` and `denies()` for boolean decisions, `inspect()` when the application needs safe denial context, and `authorize()` when denial should interrupt the current application operation:

```
$allowed = $gate->allows('record.update', $record);
$result = $gate->inspect('record.update', $record);
$gate->authorize('record.update', $record);
```

`authorize()` throws `AuthorizationException` with the generic message `This action is not authorized.` The exception exposes the ability and its `AuthorizationResult` separately so an application may deliberately map a safe message or code. Meulah never copies denial details into an HTTP response or exception message automatically.

Every callback's first parameter declares guest policy:

- `Authenticatable` or an application class implementing it makes the ability authenticated-only. A guest receives a plain denial without invoking the callback.
- Nullable `?Authenticatable` opts the ability into guest evaluation. The callback receives `null` and must decide explicitly.

Callbacks then receive the arguments passed to the gate without coercion. They must return strict `bool` or `AuthorizationResult`. Callback exceptions propagate unchanged. Closures are called directly; invokable class names are resolved through the container on each evaluation. Function names, controller strings, arrays, arbitrary method guessing, and non-invokable classes are rejected.

Ability names are case-sensitive exact names containing letters, numbers, dots, underscores, colons, or hyphens. Wildcards and surrounding whitespace are rejected. A duplicate definition throws `AuthorizationDefinitionException`; an undefined ability throws `AbilityNotDefinedException` before resolving the user. There is deliberately no replacement API.

`AuthorizationGate` owns a `Guard`, so the example uses `bind()` rather than a process-wide singleton. In a long-running worker, create a fresh guard and gate for each request. Ability definitions should be application configuration and should not capture request-specific users or mutable request data. The gate stores definitions only, resolves the actor for every decision through the guard, and keeps no static or global authorization state.

### Route authorization

[](#route-authorization)

Protect a route with the exact ability name and an explicit ordered argument resolver:

```
use Meulah\Authorization\Authorize;
use Meulah\Http\Request;
use Meulah\Routing\RouteParameters;
use Meulah\Routing\Router;

$authorizeUpdateUser = new Authorize(
    gate: $gate,
    ability: 'user.update',
    arguments: static function (
        Request $request,
        RouteParameters $parameters,
    ): array {
        return [$parameters->require('user')];
    },
);

$router->group([
    'prefix' => '/admin',
    'middleware' => [
        $requireAuthentication,
        $authorizeUpdateUser,
    ],
], static function (Router $router): void {
    $router->patch('/users/{user}', [UserController::class, 'update']);
});
```

`RouteParameters` contains the decoded string parameters for the matched route. Its `all()` and `has()` methods support explicit inspection, while `require()` throws `MissingRouteParameterException` when a configured argument is unavailable. This is a configuration error rather than an authorization denial. Meulah does not perform route-model binding or persistence queries; the application decides which strings or already-available application objects to pass to the gate.

The argument resolver must return a list in callback argument order. Associative arrays and non-array values throw `AuthorizationMiddlewareException`; a no-argument ability must deliberately return `[]`. Undefined abilities and exceptions from Gate callbacks propagate through the normal application exception boundary instead of becoming unexplained denials.

Default denials are intentionally generic:

- Browser responses use status 403 with the body `Forbidden.`.
- Requests recognized by `Request::expectsJson()` receive status 403 and `{"error":{"code":"forbidden","message":"Forbidden."}}`.
- HEAD denials preserve status and headers but the router removes the body.

The default response never exposes the message or code from `AuthorizationResult`. Applications that have reviewed those values for disclosure may pass the optional `denied` callback to `Authorize`; that callback receives the request and result and must return `ResponseInterface`.

Use this middleware order:

1. `SessionMiddleware`
2. `RequireAuthentication`
3. `Authorize`
4. the controller

For unsafe session-backed requests, global CSRF middleware also runs after the session middleware and before route middleware. `RequireAuthentication` must precede `Authorize` when guests should receive the application's explicit 401 response or authentication redirect. `Authorize` alone treats a denied guest like every other denied actor and returns 403; it never redirects or guesses a login route.

Nested route groups preserve declaration order. The router supplies matched parameters by cloning route-aware middleware for each dispatch, so reusing one `Authorize` definition across routes or requests does not retain another request's parameters in long-running workers.

Method-spoofed requests are authorized against the effective method. The resolver may inspect both `$request->method()` and `$request->originalMethod()` when that distinction is part of an ability. Authorization happens before the controller and does not change request input.

Deliberately omitted from this foundation are roles, permission tables, persistence adapters, policies, route-model binding, controller helpers, template directives, ability discovery, wildcard abilities, before/after hooks, and magic administrator or super-user bypasses.

CSRF protection
---------------

[](#csrf-protection)

Session-backed forms must register CSRF middleware globally. Use the same session instance for the form helper and middleware:

```
use Meulah\Security\Csrf\Csrf;
use Meulah\Security\Csrf\VerifyCsrfToken;

$csrf = new Csrf($session);

$app->middleware(new VerifyCsrfToken(
    $session,
    except: ['/webhooks/payment-provider'],
));
```

Render the hidden field inside every state-changing server-rendered form:

```

```

Tokens are generated from 32 cryptographically random bytes and encoded as exactly 64 lowercase hexadecimal characters. The encoded token and a SHA-256 fingerprint of the session identifier are stored in the session. Supplied and stored values must have the expected shape before comparison with `hash_equals()`.

The token remains valid across ordinary requests while the session identifier is unchanged. Regenerating the session identifier immediately invalidates the old token; the next call to `token()` or `field()` creates its replacement. `invalidate()` clears the token and rotates the session, so a token captured before logout cannot be replayed. `isValid()` is read-only, and malformed tokens are rejected before session access.

`VerifyCsrfToken` requires a valid token for every method except GET, HEAD, and OPTIONS. This includes POST, PUT, PATCH, DELETE, and custom unsafe methods supported by the router. Method spoofing happens first, so CSRF checks use the effective method while `originalMethod()` remains available for diagnostics.

JavaScript clients may send the token through the documented header:

```
X-CSRF-Token:

```

For URL-encoded and multipart requests, the middleware reads the parsed `_token` form field. JSON requests must use `X-CSRF-Token`; a token embedded in raw JSON is deliberately ignored. If both supported sources are present, each must be valid and their values must match. Null, array, object, truncated, oversized, malformed, stale, or conflicting values produce a 419 response without exposing the submitted or stored token.

HTML clients receive a safe `Page Expired` response. JSON clients receive the stable `csrf_token_mismatch` error code. A malformed JSON body with a valid header proceeds to normal request parsing and may produce `400 invalid_json`; without a valid CSRF token, the security check fails first with 419.

Exclusions are exact normalized paths:

```
new VerifyCsrfToken($session, except: [
    '/webhooks/payment-provider',
]);
```

Exclusions are matched case-sensitively against the application-relative request path after one percent-decoding and the framework's documented path normalization. Query strings are removed during request capture, a configured trailing slash is normalized consistently, and an application subdirectory is stripped. Duplicate internal slashes remain distinct, preventing them from silently becoming an excluded path.

Wildcards, route parameters, prefixes, query-string conditions, and malformed percent escapes are rejected, including percent-encoded forms of reserved exclusion syntax. Each excluded endpoint must therefore be an explicit security decision.

Validation
----------

[](#validation)

Validation produces separate application data and never mutates the request or source array. `Validator` remains the public entry point; rule parsing and evaluation are isolated internally so malformed rule definitions fail before user data is evaluated.

```
use Meulah\Validation\Validator;

$result = $validator->validate(
    $request->allInput(),
    [
        'name' => ['required', 'string', 'min:2', 'max:100'],
        'email' => ['required', 'string', 'email'],
        'age' => ['nullable', 'integer', 'min:18'],
    ],
);

if (!$result->isValid()) {
    $errors = $result->errors();
    $emailError = $result->error('email');
}

$data = $result->validated();
```

`validated()` contains only declared fields that passed every applicable rule. Optional missing fields, unrelated input, and invalid fields are omitted. Validation has no default-value feature: a missing value remains missing, and an invalid value is never replaced.

Use `validateOrFail()` when validation should stop request handling:

```
$data = $validator->validateOrFail(
    $request->allInput(),
    ['email' => ['required', 'string', 'email']],
);
```

Failure throws `Meulah\Validation\ValidationException` with HTTP status `422`. JSON requests receive stable field errors under `error.fields`; HTML requests receive a generic response without submitted values. Error messages never include submitted values, client MIME values, or temporary upload paths.

### Presence and nullability

[](#presence-and-nullability)

Presence and validity are separate concepts:

Input state`required``present``nullable`Field absentfailsfailsfield remains omitted`null`failspassesretains `null` and skips later rulesEmpty stringfailspassesnot treated as nullWhitespace-only stringpassespassesunchanged`0`, `"0"`, or `false`passespassesunchanged or explicitly type-normalizedEmpty arrayfailspassesnot treated as null`required` considers only `null`, `''`, and `[]` empty. It does not trim strings. Use `present` plus `nullable` when a key must exist but may contain `null`; `required` and `nullable` are rejected as a conflicting rule set.

A missing field is not evaluated by ordinary type, size, comparison, or file rules. A present `null` value without `nullable` is evaluated normally and usually fails its type rules.

### Rule parsing and execution

[](#rule-parsing-and-execution)

Rules are declared as a list of strings. Rule names are case-insensitive, while parameters remain exact and are not silently trimmed or lowercased.

```
[
    'role' => ['in:admin,editor,viewer'],
    'password' => ['confirmed'],
    'backup_email' => ['same:email'],
]
```

The parser rejects:

- unknown, empty, or non-string rules;
- associative rule arrays instead of lists;
- duplicate rule names, including duplicates with different casing;
- missing, extra, duplicated, overflowing, or malformed parameters;
- surrounding whitespace in `same` field names and MIME parameters;
- multiple incompatible type rules;
- `required` combined with `nullable`;
- `email` combined with a non-string type;
- file metadata rules combined with a non-file type;
- size rules combined with boolean or file types.

Malformed definitions throw `Meulah\Validation\ValidationRuleException` with the field, rule name, and rule position where useful. Submitted values are never included.

Fields retain declaration order in `validated()` and `errors()`. Errors for a field retain declared rule order. All applicable rules are evaluated, except:

- a failed `required` rule produces one required error and stops that field;
- a present `null` with `nullable` is accepted and stops that field;
- a missing field is checked only for `required` and `present`.

Type normalization happens before rule evaluation and does not depend on where the type rule appears in the rule list.

### Integer and boolean normalization

[](#integer-and-boolean-normalization)

`integer` accepts native integers and canonical base-10 strings within the current PHP platform integer range:

- accepted: `18`, `"18"`, `"-18"`, `"0"`, and `"-0"`;
- rejected: `"+18"`, `"018"`, decimals, scientific notation, hexadecimal-like strings, surrounding whitespace, booleans, floats, arrays, objects, and out-of-range values.

Accepted integer strings become native integers in validated data.

`boolean` accepts only these exact values:

- true: `true`, `1`, `"1"`, `"true"`;
- false: `false`, `0`, `"0"`, `"false"`.

Mixed case, surrounding whitespace, `yes`, `no`, `on`, `off`, floats, arrays, and objects are rejected. Accepted forms become native booleans.

### Strings, email, arrays, and size

[](#strings-email-arrays-and-size)

`string` accepts only native PHP strings. It does not trim, lowercase, normalize Unicode, or otherwise alter the value.

For strings, `min`, `max`, and `between` count Unicode code points through PCRE, so `mbstring` is not required. A combining sequence can count as multiple code points even when displayed as one grapheme. Invalid UTF-8 cannot satisfy a string size rule.

`email` uses PHP's strict email filter on the original string. Leading or trailing whitespace is rejected, case is preserved, and internationalized domains are not converted to ASCII automatically.

`array` accepts indexed, associative, empty, and nested PHP arrays. Objects and `Traversable` values are not converted. Array size rules count only the top-level number of elements.

`min`, `max`, and `between` support:

- native or explicitly normalized integers by numeric value;
- strings by Unicode code-point count;
- arrays by top-level item count.

Native floats and unsupported value types fail size rules. Numeric rule parameters must be canonical finite decimal values.

### Strict comparisons

[](#strict-comparisons)

`same:other` fails when the comparison field is missing. It compares both fields with strict equality after each declared field has undergone its own explicit type normalization.

`confirmed` compares a field with `{field}_confirmation`. A missing confirmation fails. The confirmation value is normalized using the subject field's integer or boolean rule before strict comparison.

`in` also uses strict equality. Allowed parameters are converted only when the validated value has an accepted integer or boolean type. For example, integer `1` matches `in:1` but does not match `in:01`. Error output says only that the value is not allowed; it does not echo the configured list.

### File validation

[](#file-validation)

Uploads are not merged into `Request::allInput()`. Add selected files explicitly:

```
$data = $validator->validateOrFail(
    [
        ...$request->allInput(),
        'avatar' => $request->file('avatar'),
    ],
    [
        'avatar' => [
            'required',
            'file',
            'max_size:2097152',
            'detected_mime:image/jpeg,image/png',
        ],
    ],
);
```

`file` accepts only a valid, unmoved `UploadedFile`. Missing optional files remain omitted; invalid uploads, moved files, vanished temporary files, ordinary arrays, and arrays of uploads fail.

`max_size` uses an inclusive byte boundary, so a five-byte file passes `max_size:5`. Zero-byte files can pass `max_size:0`. The limit must fit the platform integer range.

`detected_mime` compares exact server-detected MIME types case-insensitively. Client filenames and client MIME declarations are ignored. MIME parameters, wildcards, whitespace, and duplicate MIME values are rejected. Detection and validity failures produce safe errors without filesystem paths.

The first rule catalogue remains intentionally small: `required`, `present`, `nullable`, `string`, `integer`, `boolean`, `array`, `email`, `min`, `max`, `between`, `in`, `same`, `confirmed`, `file`, `max_size`, and `detected_mime`. Database-aware rules, DTO hydration, and implicit string normalization remain outside this layer.

Middleware
----------

[](#middleware)

Middleware can inspect a request, return a response immediately, or delegate to the next handler. The first registered middleware is the outermost layer:

```
use Meulah\Http\Middleware;
use Meulah\Http\Request;
use Meulah\Http\RequestHandler;
use Meulah\Http\Response;

final class AddRequestHeader implements Middleware
{
    public function process(Request $request, RequestHandler $next): Response
    {
        $response = $next->handle($request);

        return $response->withHeader('X-Framework', 'Meulah');
    }
}
```

Register middleware for every request in `bootstrap.php`:

```
$app->middleware(new AddRequestHeader());
```

Or attach middleware to one route:

```
$router
    ->get('/account', $accountHandler)
    ->middleware($requireAuthentication);
```

Middleware must return a `ResponseInterface`. It may short-circuit the pipeline without calling `$next`, which is useful for authentication, authorization, maintenance mode, CORS preflight, and rate limiting. Exceptions thrown anywhere in the pipeline are rendered by Meulah's configured exception handler.

Events
------

[](#events)

The application owns one synchronous event dispatcher and registers it in the container under EventDispatcher:

```
use Meulah\Event\EventDispatcher;

$events = $app->events();
$sameDispatcher = $app->container()->get(EventDispatcher::class);
```

Register listeners explicitly against one concrete event class:

```
$events->listen(UserRegistered::class, SendWelcomeEmail::class);
$events->listen(UserRegistered::class, function (UserRegistered $event): void {
    // Additional synchronous work.
});

$event = new UserRegistered($user);
$returned = $events->dispatch($event);
```

Listener classes must be invokable. They are resolved through the application container when dispatched, so constructor dependencies and configured singleton lifetimes work normally:

```
final class SendWelcomeEmail
{
    public function __construct(private readonly Mailer $mailer)
    {
    }

    public function __invoke(UserRegistered $event): void
    {
        $this->mailer->sendWelcomeMessage($event->user);
    }
}
```

Dispatch is synchronous and listeners run in registration order. Registering the same closure, callable, or listener class more than once is deliberate: each registration produces one invocation. A listener may require at most the event argument, cannot receive it by reference, and its declared type must accept the registered event class. Incompatible signatures are rejected during registration.

Listener return values are ignored, event mutation is visible to later listeners, and `dispatch()` returns the same event object. An exception immediately stops dispatch and propagates unchanged. Nested dispatch of another event is supported; recursively dispatching the same event object throws `EventDispatchException`, and the in-progress marker is always cleared so a long-running dispatcher retains no per-dispatch state.

Registration matching is intentionally exact: dispatching a child class does not discover listeners registered for its parent or interfaces. A listener registered directly for that child may still type its parameter as a compatible parent, interface, `object`, or `mixed`. This version has no queues, event discovery, wildcard listeners, subscribers, priorities, realtime broadcasting, annotations, or attributes.

Treat listener registration as application-lifetime configuration. In a long-running worker, do not add request-specific closures to the shared dispatcher; registered listeners intentionally remain until the dispatcher itself is discarded.

Configuration
-------------

[](#configuration)

Configuration files live in `config/` and return plain PHP arrays. They are loaded into a small repository with dot-notation and strict typed access:

```
$environment = $app->config()->string('app.environment');
$database = $app->config()->array('database');
```

Environment-specific values belong in the ignored root `.env`. `.env.example` documents the available variables and is safe to commit.

Database drivers
----------------

[](#database-drivers)

Meulah supports MySQL, PostgreSQL, and SQLite through PDO. Select the driver in `.env`:

```
DB_DRIVER=mysql
```

Use `pgsql` or `postgresql` for PostgreSQL and `sqlite` for SQLite. Applications create a connection from the loaded configuration:

```
use Meulah\Database\Connection;

$connection = Connection::fromConfig($app->config()->array('database'));
```

MySQL uses port `3306` by default and PostgreSQL uses `5432`. SQLite reads `DB_PATH`; relative paths are resolved from the project root, while `:memory:` creates an in-memory database. The selected PDO driver (`pdo_mysql`, `pdo_pgsql`, or `pdo_sqlite`) must be installed.

Migrations
----------

[](#migrations)

Migration files live in `database/migrations` by default, are ordered by filename, and return an object implementing `Meulah\Database\Migration`:

```
