efnext / security-framework
ef.next Security Framework: fail-closed tenant isolation, tamper-evident audit log and safe public identifiers for Laravel.
Requires
- php: ^8.3
- ext-json: *
- illuminate/auth: ^13.0
- illuminate/bus: ^13.0
- illuminate/cache: ^13.0
- illuminate/console: ^13.0
- illuminate/contracts: ^13.0
- illuminate/database: ^13.0
- illuminate/events: ^13.0
- illuminate/http: ^13.0
- illuminate/queue: ^13.0
- illuminate/routing: ^13.0
- illuminate/session: ^13.0
- illuminate/support: ^13.0
- symfony/http-kernel: ^7.4 || ^8.0
Requires (Dev)
- larastan/larastan: ^3.0
- laravel/pint: ^1.20
- laravel/sanctum: ^4.0
- orchestra/testbench: ^11.0
- pestphp/pest: ^4.0
- pestphp/pest-plugin-laravel: ^4.0
- phpstan/phpstan: ^2.0
Suggests
- laravel/octane: Supported; the tenant context is cleared on every request boundary when Octane is installed.
- laravel/sanctum: Required for tenant-bound API tokens (Efnext\Security\Tenant\Http\Middleware\EnsureTokenTenant).
Provides
None
Conflicts
None
Replaces
- efnext/security-addressing: v1.0.0-beta.2
- efnext/security-audit: v1.0.0-beta.2
- efnext/security-tenant: v1.0.0-beta.2
This package is auto-updated.
Last update: 2026-10-29 20:58:02 UTC
README
Fail-closed tenant isolation, a tamper-evident audit log and safe public identifiers for Laravel 13 on PHP 8.3, 8.4 and 8.5 (see Compatibility).
Status: pre-release. The current version line is 1.0.0-beta (see the CHANGELOG); the API can still change before 1.0.0, and every change is listed there. Releases are signed tags with SBOMs (Releasing).
Website: security-framework.ef-next.com — what the framework is for, the Cyber Resilience Act context, news and contact. This repository holds the code and the complete documentation.
| Package | What it does |
|---|---|
efnext/security-tenant |
Resolves the tenant of a request, job or command; refuses every query on tenant-owned models without a tenant; enforces memberships and roles; propagates the tenant through queues. |
efnext/security-audit |
Appends events to per-tenant hash chains with sequence numbers, database triggers against UPDATE/DELETE, HMAC signatures with keys outside the database, external anchors and a verifier. |
efnext/security-addressing |
Unguessable, readable public ids (dom_7f3k9zq2m4x8c1bt) and global object ids (example-service:domain:dom_…) for models. |
One distribution, three components: the efnext/security-framework package contains all three (the component names are reserved via replace and will become separately installable once the monorepo is split for a stable release). Composer's default stability is stable, so a pre-release has to be asked for explicitly:
composer require "efnext/security-framework:^1.0@beta" php artisan vendor:publish --tag=security-tenant-config --tag=security-audit-config --tag=security-addressing-config php artisan migrate php artisan audit:key:generate # put the output into .env php artisan security:doctor # would this installation keep the guarantees below? exit 1 if not
Read What it does not do before relying on any of it.
Ten-minute integration
All three packages in one walk-through; each step is complete on its own.
1. Tenants and users
use Efnext\Security\Tenant\Membership\HasTenantMemberships; class User extends Authenticatable { use HasTenantMemberships; } $tenant = Tenant::create(['name' => 'Acme', 'slug' => 'acme']); $user->grantTenantMembership($tenant->tenantId(), 'owner');
2. Tenant-owned models
use Efnext\Security\Tenant\Eloquent\BelongsToTenant; class Invoice extends Model { use BelongsToTenant; // every query is scoped; no tenant -> exception } // migration Schema::create('invoices', function (Blueprint $table) { $table->id(); $table->tenantId(); // uuid column + FK to tenants + index + UNIQUE(tenant_id, id) // ... }); Schema::create('invoice_lines', function (Blueprint $table) { $table->id(); $table->tenantId(); $table->tenantForeignId('invoice_id', 'invoices')->cascadeOnDelete(); // FK (tenant_id, invoice_id) -> invoices(tenant_id, id) });
The composite foreign key is defence in depth below Eloquent: even raw SQL cannot attach a line to another tenant's invoice. Optional relations use nullable: true with cascadeOnDelete() or restrictOnDelete(); nullOnDelete() is refused at migration time because a composite key cannot set only one of its columns to NULL (MySQL/MariaDB would reject the constraint, PostgreSQL the delete).
3. Public ids (Addressing)
use Efnext\Security\Addressing\Contracts\ExternallyAddressable; use Efnext\Security\Addressing\Eloquent\HasGlobalObjectId; use Efnext\Security\Addressing\Eloquent\HasPublicId; class Invoice extends Model implements ExternallyAddressable { use BelongsToTenant; use HasPublicId; // inv_7f3k9zq2m4x8c1bt, assigned on create, immutable, used for route binding use HasGlobalObjectId; // billing:invoice:inv_7f3k9zq2m4x8c1bt (SECURITY_SERVICE_NAME=billing) public static function publicIdPrefix(): string { return 'inv'; } public static function resourceType(): string { return 'invoice'; } } // migration: add to the table from step 2 $table->publicId(); // unique, indexed column (default name: public_id)
{invoice} in a route now binds by public id (an unknown or malformed id is a 404, never an exception), Invoice::query()->wherePublicId($id) and ->whereGlobalObjectId($id) look one up, $invoice->publicId() / ->globalObjectId() hand them out. Use the public id in URLs, APIs and logs; use the global object id when another service or an audit trail has to name the object unambiguously. Public ids stop enumeration, they are not authorization: the tenant middleware and your policies still decide who may see the invoice. Bulk insert() bypasses Eloquent events and therefore assigns no id (the column is NOT NULL, so the database refuses the row); create through the model.
4. Routes
Route::middleware(['auth:sanctum', 'tenant'])->group(function () { Route::get('/invoices', [InvoiceController::class, 'index']); // Invoice::all() is scoped Route::delete('/invoices/{invoice}', ...)->middleware('tenant.role:admin'); // {invoice} is bound after the tenant is resolved });
The tenant comes from X-Tenant-Id (internal id, ten_… public id, global object id or slug), a {tenant} route parameter, the session or a subdomain, in the configured order. Unknown tenant: 404. Suspended tenant: 403. No membership: 403 plus an audit event. The tenant middleware registers itself in Laravel's middleware priority list right before SubstituteBindings, so implicit route model binding of tenant-owned models works inside the web and api groups whatever order the route declares.
5. Audit
use Efnext\Security\Audit\Facades\Audit; Audit::action('domain.verified')->on($domain)->with(['method' => 'dns'])->record(); Audit::action('auth.login_failed')->on('user', $email)->denied()->record();
Actor, tenant, IP, user agent, request id and correlation id are filled in automatically and are part of the hash. Run php artisan audit:verify and php artisan audit:anchor from the scheduler.
// Business change and audit record commit together; conflicts on the tenant's // chain (deadlocks, serialization failures, MariaDB snapshot-isolation errors) // are retried with back-off. Prefer this over DB::transaction() whenever a // transaction contains an audit write. Audit::transaction(function () use ($invoice) { $invoice->markPaid(); Audit::action('invoice.paid')->on($invoice)->record(); });
The closure may run more than once: keep e-mails, API calls and other side effects outside of it (queue them after it returns, or make them idempotent).
6. Operations
php artisan security:doctor # after every deployment and from monitoring (--json; --strict = deployment gate of the high-security profile) php artisan audit:anchor # scheduler, every few minutes php artisan audit:verify # scheduler, at least daily
In APP_ENV=production the audit provider refuses to boot without a signing key, on SQLite, with append-only enforcement disabled or without a readable anchor store (AUDIT_FAIL_FAST=false is the deliberate, temporary exception). Set AUDIT_REQUIRE_EXTERNAL_ANCHOR=true once your object-locked bucket is in place: production then also refuses to boot with database-only anchors, and the doctor reports them as a failure instead of a warning.
7. Jobs and commands
ProcessInvoices::dispatch(); // tenant travels with the job automatically Tenancy::run($tenantId, fn () => Invoice::count()); // explicit context in code
php artisan tenant:run acme reports:generate # explicit context on the CLI
Where next
| You want to … | Read |
|---|---|
| understand what the framework does and does not promise | What it does not do, Threat model, Concepts |
| integrate it into an application | Getting started (a complete first integration with sample outputs), then the guides: Tenancy, Addressing, Auditing, Jobs and commands, Sanctum tokens; the Integration checklist before go-live |
| see it all in one working application | examples/laravel-app — a stock Laravel 13 app plus fifteen files, installed and tested in CI on every push |
| look something up | Configuration (every key and environment variable), Artisan commands, Events, Exceptions, Public API, Compatibility |
| run it in production | Production guide (secrets, database, anchors on object storage, the deployment gate), Operations and its runbooks: Monitoring, Key rotation, Incident response; .env.example lists every variable |
| diagnose a problem | php artisan security:doctor (--json, --strict), then Troubleshooting by symptom |
| know how it works inside | Architecture and the diagrams: System overview, Tenant request lifecycle, Audit write path and verification, Trust boundaries, Data model |
| contribute or cut a release | CONTRIBUTING.md, Testing, Releasing, SUPPORT.md |
| let an AI agent integrate it | AGENTS.md — the contract an agent has to keep, and how it verifies its own work; llms.txt indexes the documentation for agents |
Documentation is written in English first. German (README.de.md, docs/de/) and Swedish (README.sv.md, docs/sv/) editions of the user and operator pages follow each release; each translated page names the English source and the version it corresponds to.
AI coding agents
The framework is meant to be integrated by people and by coding agents alike. It ships the rules an agent has to keep in three lengths:
- Laravel Boost: in an application with Boost,
php artisan boost:install(orboost:update) listsefnext/security-frameworkamong the third-party packages; once it is selected, Boost loads the package's guideline into the agent's context – the rules that keep the guarantees, in resources/boost/guidelines/core.blade.php – and installs the skillefnext-security-integration: the integration procedure and how to verify it, loaded when the agent needs it. - AGENTS.md: the complete contract, for agents without Boost and for agents that change the framework itself.
- llms.txt: an index of the documentation.
Refusals and doctor findings name their fix, security:doctor --json is machine-readable, and the exit codes of security:doctor --strict and audit:verify tell an agent whether its work keeps the guarantees.
How this was built
The framework was written by an AI coding agent, Claude by Anthropic, under the direction of the maintainer, Enrico Engel (ef.next): he set the architecture and the security decisions and accepted every change. It went through several review rounds; the fixes they led to are listed in the CHANGELOG.
So we do not ask you to trust the code – we ask you to check it. The evidence lives in this repository and runs on every push:
- the test suite on SQLite, PostgreSQL, MySQL and MariaDB, including concurrent writers, with a coverage floor;
- mutation testing over the security core as a CI gate;
- PHPStan at level 8, Pint and
composer audit; - known-answer vectors that recompute audit hashes and signatures without this code base;
- signed release tags, verified against the trust root in the repository, with SBOMs and build provenance (Releasing).
What is still missing is an independent security audit by a third party. Until there is one, treat the framework as the beta it is labelled, and report what you find as described in SECURITY.md.
Guarantees and their conditions
| The framework guarantees … | … provided that |
|---|---|
Eloquent queries on models using BelongsToTenant never return another tenant's rows and never run without a tenant. |
You do not call withoutGlobalScope(TenantScope::class), do not use DB::table() for those tables and do not run raw SQL. Grep for these in code review. |
| A record cannot be created for, moved to, updated in or deleted from a foreign tenant through Eloquent, even if the model instance was loaded in another context. | Same as above. Mass assignment of the tenant column is blocked unless you add it to $fillable yourself. |
| A request reaches your controller only if the user is an active, unexpired member of an existing, active tenant (and has the role you asked for). | The route carries auth and the tenant group (or tenant.resolve + tenant.member). |
| An audit record cannot be changed or deleted through the database without leaving a trace. | Triggers are installed (the migration refuses unsupported drivers), a signing key is configured, and audit:anchor writes to a store outside the database. Without the key, a database-level attacker can rewrite a chain consistently; without external anchors, they can truncate it. security:doctor checks all of this; AUDIT_REQUIRE_EXTERNAL_ANCHOR=true makes the external store a boot condition. |
| A child row can only reference a parent of the same tenant, whatever wrote it. | The child table uses $table->tenantForeignId() / tenantForeignUuid() / tenantForeignUlid() and the parent was created with $table->tenantId() (composite key). |
| Concurrent writers never fork a chain and do not deadlock on it. | The audit tables live on a database that honours SELECT … FOR UPDATE (PostgreSQL, MySQL/InnoDB, MariaDB). Business transactions that contain audit writes are wrapped in Audit::transaction() (retries concurrency conflicts with back-off) and, for brand-new tenants, Audit::prepareChain() ran once. |
| Public ids are unguessable. | You keep the default entropy (80 bits). They are still not an authorization mechanism. |
Compatibility
| Supported – hard CI gates | |
|---|---|
| PHP | 8.3, 8.4, 8.5 |
| Laravel | 13 |
| Databases | PostgreSQL 14–17, MySQL 8.0/8.4, MariaDB 10.11–11 (SQLite for tests only) |
Every supported combination is a required CI gate, and the Composer
constraints admit nothing else (Laravel 13 only; PHP above 8.5 installs but is
untested). Only the latest minor release receives fixes
(SECURITY.md); the release process is in docs/maintainers/releasing.md.
Development
Everything runs in Docker; the host needs nothing but Docker Desktop:
./start-security-framework.sh # composer audit + selfcheck + Pint + PHPStan + tests on sqlite, pgsql, mysql, mariadb ./start-security-framework.sh mutate # mutation testing on the security core
The other modes (test <db>, concurrency, matrix, load, shell, down, PHP_VERSION=, LARAVEL=, DEPS=), the quality gates CI enforces and the rules for pull requests are in CONTRIBUTING.md and docs/maintainers/testing.md.
Using it from a Laravel Sail product
The framework repository is not a Laravel application, so it ships its own
compose.yaml instead of Sail; the workflow is the same (nothing but Docker on
the host). To develop against it from a Sail-based product, add a path
repository and require the dev version:
{
"repositories": [
{"type": "path", "url": "../security-framework", "options": {"symlink": true}}
]
}
sail composer require efnext/security-framework:@dev sail artisan vendor:publish --tag=security-tenant-config --tag=security-audit-config --tag=security-addressing-config sail artisan migrate sail artisan audit:key:generate
Mount the framework directory into the Sail container (volumes: in your
product's compose.yaml) so the symlink resolves inside Docker. Sail's MySQL
service needs --log-bin-trust-function-creators=1 (or a root user) for the
audit triggers; see docs/en/operations.md.
Documentation
- Getting started · Concepts · What it does not do
- Guides: Tenancy · Addressing · Auditing · Jobs and commands · Sanctum tokens · Production · Troubleshooting
- Reference: Configuration · Artisan commands · Events · Exceptions · Public API · Compatibility
- Operations: Operations · Monitoring · Key rotation · Incident response
- Design: Architecture · Threat model · Diagrams · Integration checklist
- Example application · AGENTS.md · llms.txt
- Contributing · Testing · Releasing · Support · Code of conduct
- Security policy · Changelog · Licence (MIT)