Search by

laravarc / surfacer

Declarative HTTP boundary (Surface) for Laravel — domain, prefix/version, middleware, CORS, rate limits, security headers, and API deprecation.

Maintainers

Package info

github.com/laravarc/surfacer

pkg:composer/laravarc/surfacer

Transparency log

Statistics

Installs: 2

Dependents: 1

Suggesters: 1

Stars: 1

Open Issues: 0

v1.0.0 2026-09-05 16:59 UTC

This package is auto-updated.

Last update: 2026-09-05 17:17:13 UTC


README

Declarative HTTP boundaries (Surfaces) for Laravel. A Surface is one place to define the policies that apply before a request reaches your controllers: domain, path prefix + API version, middleware, CORS, rate limiting, security headers, and deprecation/sunset headers.

Works in any Laravel 10–13 application. No other Laravarc packages required.

Installation

composer require laravarc/surfacer

Publish the config (optional):

php artisan vendor:publish --tag=surfacer-config

Quick start

1. Create a Surface definition

php artisan laravarc:surfacer make admin
# alias: php artisan larc:surfacer make admin

This creates surfaces/admin_surface.php by default (definitions_path). The command always prints the absolute target path (Target path: / File:) so you can confirm where the file landed. The file is yours — Surfacer never rewrites it automatically.

Definition files must end with _surface.php (snake_case stem + that suffix).

Example:

<?php

/**
 * @surfacer-schema 1
 */

use Laravarc\Surfacer\Definition\CorsDefinition;
use Laravarc\Surfacer\Definition\RateLimitDefinition;
use Laravarc\Surfacer\Definition\SecurityHeadersDefinition;
use Laravarc\Surfacer\Definition\VersionDefinition;
use Laravarc\Surfacer\Facades\Surfacer;

return Surfacer::define('admin')
    ->domain('admin.example.com')   // optional
    ->prefix('api')
    ->middleware(['api'])
    ->defaultVersion('v1')
    ->version('v1', function (VersionDefinition $version): void {
        // optionally mark deprecated — see below
    })
    ->version('v2')
    ->cors(function (CorsDefinition $cors): void {
        $cors->allowOrigins(['https://admin.example.com'])
            ->allowMethods(['GET', 'POST', 'PUT', 'PATCH', 'DELETE', 'OPTIONS'])
            ->allowHeaders(['Content-Type', 'Authorization', 'Accept'])
            ->supportsCredentials(false);
    })
    ->rateLimit(function (RateLimitDefinition $rate): void {
        $rate->perMinute(60)->byIp();
    })
    ->securityHeaders(function (SecurityHeadersDefinition $headers): void {
        $headers->preset('api');
    });

2. Apply the Surface around your routes

Surfacer does not invent routes. You decide what lives inside the boundary:

use Illuminate\Support\Facades\Route;
use Laravarc\Surfacer\Facades\Surfacer;

Surfacer::group('admin', function (): void {
    // Shared-by-default: registered on every version mount + default alias
    Route::get('/orders', [OrderController::class, 'index'])->name('orders.index');

    // Exception: only on v2
    Surfacer::onlyVersions(['v2'], function (): void {
        Route::get('/insights', [InsightController::class, 'index']);
    });

    // Exception: not on v2 (still on v1, alias, and any future v3+)
    Surfacer::exceptVersions(['v2'], function (): void {
        Route::get('/legacy-reports', [LegacyReportController::class, 'index']);
    });
});

// Or a single version only (bulk file):
Surfacer::group('admin', 'v2', function (): void {
    require base_path('routes/admin-v2.php');
});

With defaultVersion('v1'), shared routes are mounted at:

Path Route name for orders.index
/api/v1/orders v1.orders.index
/api/v2/orders v2.orders.index
/api/orders (default alias) orders.index

Versioned mounts get a Route::name('{version}.') prefix so dual-mounting never collides on named routes.

Version membership (registration-time)

  • Default: no filter → route belongs on all mounts of the surface (including the unversioned default alias, which uses defaultVersion as its context).
  • onlyVersions([...]) — register only when the current mount version is in the set.
  • exceptVersions([...]) — register when the current mount version is not in the set (except means “not these versions”, not “only older versions”; a route excepted from v2 still appears on v3).
  • Nested filters AND together. If a filter fails, the callback is not invoked (no side effects).
  • Empty [], unknown version keys, use outside group(), or filters on a surface with no version() declarations → throw.
  • Surfacer::currentVersion() returns the active mount key (or null outside a mount) for debug/tests only.

Anti-patterns

  • Do not branch controller response shapes on currentVersion() — split registration / controllers with onlyVersions instead.
  • Do not wrap routes in onlyVersions(['v1']) when the surface only declares v1 — that is useless ceremony; rely on shared-by-default.
  • Do not repeat the Surface prefix / version segment inside route files (avoids double prefixes).

3. Cache for production

php artisan laravarc:surfacer cache
php artisan laravarc:surfacer cache --clear

Caching evaluates definition closures once and writes a static PHP artifact. Closures are not stored in the cache.

In local / development, Surfacer auto-refreshes (re-scans definition files every boot) so new surfaces apply without a manual cache command. Override with SURFACER_CACHE_AUTO_REFRESH=true|false. laravarc:surfacer make always refreshes the cache artifact after writing a file.

Configuration: definitions_path

'definitions_path' => base_path('surfaces'),
// or a single-wildcard folder scan:
// 'definitions_path' => app_path('Http/Surfaces/*'),
  • Scan (boot / laravarc:surfacer cache / laravarc:surfacer capabilities) expands this one path. A single * is expanded with glob at scan time so new folders appear automatically.
  • Write (laravarc:surfacer make) always uses the same path. With a wildcard: existing PascalCase/Studly → existing lowercasecreate PascalCase. Filename is always {snake}_surface.php.
php artisan laravarc:surfacer make admin
php artisan laravarc:surfacer make admin --dry-run
php artisan laravarc:surfacer make admin --force

Features

Domain binding

Optional. Omit or pass null to use the application’s default host. Supports parameter patterns such as {tenant}.admin.example.com.

Prefix + version → one final path

Prefix and version are declared separately, then merged by a single PathComposer before route grouping:

  • both set → api/v1
  • only prefix → api
  • only version → v1
  • both empty → no path prefix

Callers must not concatenate these manually.

Middleware stack

  • middleware([...]) — replace the stack
  • appendMiddleware([...]) — append

CORS per Surface

Configured on the Surface itself — not via Laravel’s global config/cors.php.

CORS runs as a global middleware that matches the request by domain + path before routing. Browser OPTIONS preflights receive an early 204 without requiring an OPTIONS route.

Rate limiting per Surface

->rateLimit(function (RateLimitDefinition $rate): void {
    $rate->perMinute(60)->byIp();       // or ->byActor()
    // $rate->byResolver(MyKeyResolver::class);
});

Named limiters (surfacer:{name}) are registered once at boot, not per request. Custom keys must be a class implementing RateLimitKeyResolver (FQCN only — closures are not cache-safe).

Security headers

->securityHeaders(function (SecurityHeadersDefinition $headers): void {
    $headers->preset('api')           // or 'web'
        ->set('X-Robots-Tag', 'noindex')
        ->merge(['X-Permitted-Cross-Domain-Policies' => 'none']);
});

Deprecation & Sunset (RFC 8594)

->version('v1', function (VersionDefinition $version): void {
    $version->deprecate(function ($d): void {
        $d->since('2025-06-01')
            ->sunset('2026-06-01')
            ->message('Use v2.')
            ->successor('v2');
    });
})

Deprecated versions automatically receive Deprecation and Sunset response headers. The package also dispatches DeprecatedSurfaceAccessed for logging or metrics — it does not store logs itself.

Default version

When versions are declared, defaultVersion('v1') is required. It is never inferred from declaration order. The default version is also mounted without the version path segment (see table above).

Reading resolved Surfaces from outside

use Laravarc\Surfacer\Contracts\SurfaceRepository;
use Laravarc\Surfacer\Facades\Surfacer;

$surface = Surfacer::get('admin');
$all = app(SurfaceRepository::class)->all();

ResolvedSurface is immutable and array-serializable — suitable for tooling, generators, or external integrations.

Staying up to date without file rewrites

Definition files include @surfacer-schema N. To see what newer package versions offer — without modifying your files:

php artisan laravarc:surfacer capabilities

Adopt new DSL methods manually when you choose. Breaking changes follow semantic versioning (major bump).

Artisan reference

Command Purpose
laravarc:surfacer make {path} Scaffold {snake}_surface.php (--force, --dry-run)
laravarc:surfacer cache Resolve & cache all surfaces
laravarc:surfacer cache --clear Clear the cache artifact
laravarc:surfacer capabilities Report newer schema capabilities (read-only)

Alias: larc:surfacer → same command.

Testing

composer test

Package tests call MountContext::reset() in setUp(). If you write app tests that call Surfacer::group() / onlyVersions(), reset the mount stack in your own setUp() as well (Laravarc\Surfacer\Support\MountContext::reset()).

Membership filters run at registration time. After changing onlyVersions / exceptVersions or surface versions in production, rebuild Laravel’s route cache (php artisan route:cache) the same way you would after editing routes/*.php.

License

MIT — see LICENSE.