laravarc / surfacer
Declarative HTTP boundary (Surface) for Laravel — domain, prefix/version, middleware, CORS, rate limits, security headers, and API deprecation.
Requires
- php: ^8.2
- illuminate/cache: ^10.0|^11.0|^12.0|^13.0
- illuminate/console: ^10.0|^11.0|^12.0|^13.0
- illuminate/contracts: ^10.0|^11.0|^12.0|^13.0
- illuminate/events: ^10.0|^11.0|^12.0|^13.0
- illuminate/filesystem: ^10.0|^11.0|^12.0|^13.0
- illuminate/http: ^10.0|^11.0|^12.0|^13.0
- illuminate/routing: ^10.0|^11.0|^12.0|^13.0
- illuminate/support: ^10.0|^11.0|^12.0|^13.0
Requires (Dev)
- larastan/larastan: ^3.10
- laravel/pint: ^1.29
- orchestra/testbench: ^9.0|^10.0
- pestphp/pest: ^3.0|^4.0
Suggests
None
Provides
None
Conflicts
None
Replaces
None
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
defaultVersionas 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 (exceptmeans “not these versions”, not “only older versions”; a route excepted fromv2still appears onv3).- Nested filters AND together. If a filter fails, the callback is not invoked (no side effects).
- Empty
[], unknown version keys, use outsidegroup(), or filters on a surface with noversion()declarations → throw. Surfacer::currentVersion()returns the active mount key (ornulloutside a mount) for debug/tests only.
Anti-patterns
- Do not branch controller response shapes on
currentVersion()— split registration / controllers withonlyVersionsinstead. - Do not wrap routes in
onlyVersions(['v1'])when the surface only declaresv1— 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 withglobat scan time so new folders appear automatically. - Write (
laravarc:surfacer make) always uses the same path. With a wildcard: existing PascalCase/Studly → existing lowercase → create 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 stackappendMiddleware([...])— 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.