AssegaiPHP 0.9.5: CORS at the Application Boundary

AssegaiPHP 0.9.5 replaces front-controller CORS shims with an application-level policy for credentialed origins, cached preflights, dynamic configuration, and consistent real responses.

AssegaiPHP 0.9.5: CORS at the Application Boundary

Modern applications do not always fit behind one origin.

An API might be served from api.example.com while its management console lives at console.example.com. The same split appears during local development, where a frontend development server and an AssegaiPHP API usually run on different ports.

That arrangement is intentional, but it means every browser request crosses an origin boundary. Simple reads may work without much ceremony. JSON POST requests and methods such as PATCH and DELETE normally require a preflight before the browser sends the real request.

AssegaiPHP 0.9.5 introduces application-level CORS support for exactly this kind of architecture.

Instead of relying on unconditional headers and a bare OPTIONS response in index.php, an application can now define one policy through enableCors(). Core applies that policy to both preflight and actual responses across the default PHP and OpenSwoole runtimes.

Why the old shim was not enough

Early project scaffolds included a few CORS headers at the top of the front controller and completed every OPTIONS request immediately.

That was useful while the framework was young, but it was not a complete CORS layer.

There were several practical problems:

  • a wildcard Access-Control-Allow-Origin was paired with Access-Control-Allow-Credentials: true, which browsers cannot accept for credentialed requests
  • there was no Access-Control-Max-Age, so browsers had to repeat non-simple preflights
  • responses did not vary by Origin, which could allow shared caches to reuse the wrong response metadata
  • every OPTIONS request was intercepted, including requests that were not CORS preflights
  • the preflight response was disconnected from the response pipeline used by the real route
  • failures such as 404, 405, or 500 could reach the browser without CORS headers and be reported as generic CORS errors

The last point is especially frustrating during development. A request can pass its preflight and then fail for a perfectly ordinary application reason. If the error response does not carry the applicable CORS headers, browser tooling hides that useful HTTP result behind a misleading CORS message.

CORS needs to live close enough to the application response pipeline to describe the response the browser actually receives.

Enabling CORS in 0.9.5

CORS is opt-in. Existing applications keep their current behavior until they call enableCors() before run():

function bootstrap(): void
{
  $app = AssegaiFactory::createFromProject(AppModule::class, __DIR__);
  $app->enableCors();
  $app->run();
}

The default policy allows any origin without credentials. It advertises the following methods during a valid preflight:

GET, HEAD, PUT, PATCH, POST, DELETE

When allowedHeaders is not configured, Core reflects the browser's requested header names and adds the corresponding Vary value.

That default is useful for public APIs, but authenticated consoles should normally use an explicit origin policy.

A credentialed console configuration

Suppose the API runs at https://api.example.com, the production console runs at https://console.example.com, and the local console runs at http://localhost:5173.

The application can allow those origins directly:

$app->enableCors([
  'origin' => [
    'http://localhost:5173',
    'https://console.example.com',
  ],
  'credentials' => true,
  'methods' => ['GET', 'HEAD', 'POST', 'PUT', 'PATCH', 'DELETE'],
  'allowedHeaders' => [
    'Content-Type',
    'Authorization',
    'X-CSRF-Token',
    'X-API-Key',
  ],
  'exposedHeaders' => ['Location'],
  'maxAge' => 600,
]);

For a matching request, Core reflects the allowed origin, adds Access-Control-Allow-Credentials: true, and emits Vary: Origin.

An origin outside the allowlist receives no Access-Control-Allow-Origin header. The policy fails closed instead of quietly broadening access.

The browser request must opt into credentials as well:

await fetch('https://api.example.com/orders/42', {
  method: 'PATCH',
  credentials: 'include',
  headers: {
    'Content-Type': 'application/json',
    'X-CSRF-Token': csrfToken,
  },
  body: JSON.stringify({ status: 'ready' }),
});

Core rejects a configuration that combines credentials: true with a wildcard origin. That catches an invalid browser policy when the application boots instead of leaving developers to diagnose it from a failed request later.

Preflight is now a real application concern

Not every OPTIONS request is a CORS preflight.

Core recognizes a preflight only when all three of these conditions are present:

  • the request method is OPTIONS
  • the request includes Origin
  • the request includes Access-Control-Request-Method

By default, Core completes a recognized preflight before opening a session or preparing routing, middleware, and controller work. Ordinary OPTIONS routes continue through the normal application pipeline.

The preflight response can include:

  • allowed methods
  • allowed or reflected request headers
  • credential support
  • a configurable success status
  • Access-Control-Max-Age for browser-side caching
  • the Vary values required by the chosen policy

Applications that deliberately route preflight requests themselves can set preflightContinue to true. Most applications should keep the default and let the CORS layer complete them early.

A passing preflight is not a route check

AssegaiPHP's CORS policy is application-wide, similar to the CORS support exposed by NestJS.

That means a successful preflight says the origin, method, and headers are acceptable to the application policy. It does not promise that a particular route exists or that the current user may perform the operation.

The real request must still pass through routing, authentication, guards, validation, and application logic.

What changes in 0.9.5 is that the resulting response receives the same applicable CORS policy. A real 404, 405, or 500 can now remain visible to browser code and developer tools instead of being masked by a missing header.

Static resources also pass through the response emission boundary, so the policy behaves consistently across framework response types.

Dynamic policies when an allowlist is not enough

Some applications resolve allowed origins from tenant configuration or separate policies by path.

An origin callback can make that decision from the incoming origin and request:

use Assegai\Core\Http\Requests\Interfaces\RequestInterface;

$app->enableCors([
  'origin' => static function (string $origin, RequestInterface $request): bool {
    return str_ends_with($origin, '.internal.example.com');
  },
  'credentials' => true,
]);

For broader request-level control, enableCors() also accepts a callback that returns an options array, a CorsOptions object, the default policy, or no policy for the current request.

This keeps multi-tenant and control-plane rules at one application boundary without forcing them into the front controller or duplicating them across controllers.

Migrating an existing project

An existing scaffold should have one authoritative CORS layer.

To move the policy into Core:

  1. Remove unconditional Access-Control-Allow-* calls from the project-root index.php.
  2. Remove the top-level block that answers every OPTIONS request and exits.
  3. Remove Apache or reverse-proxy CORS directives that would overwrite the framework's headers.
  4. Configure $app->enableCors(...) in bootstrap.php before $app->run().

A reverse proxy can still own CORS if that is the architecture you prefer. The important part is avoiding two layers that compete to set Access-Control-Allow-Origin or credentials behavior.

Custom implementations of AppInterface, including test doubles, also need to implement the new method:

use Assegai\Core\Http\Cors\CorsOptions;

public function enableCors(CorsOptions|array|callable|null $options = null): static;

CORS is not authentication or CSRF protection

CORS controls whether browser code from another origin can read a response. It does not authenticate a user, authorize an action, or make a state-changing request safe from cross-site request forgery.

Applications that use cross-site cookies still need appropriate SameSite and Secure cookie attributes. Credentialed POST, PUT, PATCH, and DELETE routes should also use an intentional CSRF defense.

The new API makes the browser boundary correct. The rest of the application's security model remains just as important.

Keeping the test suite ready for newer PHP releases

The 0.9.5 work also updates the development test baseline to Codeception ^5.3.5 for PHP 8.5 compatibility.

composer test now enables the CLI setting Codeception needs, reports all PHP errors, and converts deprecations into test failures. Deprecation warnings are not hidden. They stay visible so the framework can address compatibility problems before they become runtime breaks in later PHP versions.

Why this release matters

CORS looks small when it is reduced to four response headers. In a real application, it sits across origin selection, credentials, caches, preflight behavior, error handling, and every response path a browser might observe.

AssegaiPHP 0.9.5 gives that concern a proper place in the application lifecycle.

For teams running an API and console on separate origins, the result is less front-controller plumbing, fewer repeated preflights, safer credential handling, and errors that say what actually went wrong.

Read the CORS configuration and migration guide or review the AssegaiPHP Core 0.9.5 release notes for the complete option reference and upgrade checklist.