Resolve a "platform" (tenant, site, or API client) on every request — by hostname, token, context string, or session — and make it available everywhere via platform().
composer require mindtwo/laravel-platform-managerphp artisan vendor:publish --provider="mindtwo\LaravelPlatformManager\LaravelPlatformManagerProvider" --tag=configThis publishes config/platform.php.
php artisan vendor:publish --provider="mindtwo\LaravelPlatformManager\LaravelPlatformManagerProvider" --tag=migrations
php artisan migrate// config/platform.php
return [
// Eloquent model used as the platform. Swap this for your own model that extends Platform.
'model' => \mindtwo\LaravelPlatformManager\Models\Platform::class,
// HTTP headers used for M2M token auth.
'header_names' => [
'token' => 'X-Platform-Token',
// Legacy header accepted during a grace period. Set to null to disable.
'token_legacy' => 'X-Context-Platform-Public-Auth-Token',
],
// Session key used by the session resolver.
'session_key' => 'platform_id',
];Register the middleware alias in your application's middleware stack or use it inline on routes:
// routes/api.php
Route::middleware('resolve-platform:token')->group(function () {
// platform() is available here
});The resolve-platform alias is registered automatically by the service provider.
Pass one or more strategies separated by |. The first one that returns a match wins.
| Strategy | Resolves by |
|---|---|
host |
Host header matched against hostname / additional_hostnames (supports * wildcards) |
token |
X-Platform-Token header matched against an active, non-expired auth_tokens record |
context |
X-Platform-Context header matched against the context column |
session |
Platform PK stored in the session via platform()->saveToSession() |
// Try token first, fall back to hostname
Route::middleware('resolve-platform:token|host')->group(function () { ... });If no strategy resolves a platform the middleware aborts with a 404.
The global platform() function returns the singleton Platform context object.
// Check whether a platform has been resolved
platform()->isResolved(); // bool
// Get the underlying Eloquent model
platform()->get(); // ?PlatformModel
// Read any model attribute directly
platform()->hostname;
platform()->uuid;
// Read platform settings (dot notation)
platform()->setting('mail.from');
platform()->setting('billing.plan', 'free');
// Which resolver matched
platform()->resolver(); // 'token' | 'host' | 'context' | 'session' | ...Scopes control what operations a resolved platform is allowed to perform. There are two layers:
- Platform baseline scopes — stored on the
platformsrow itself, always active regardless of how the platform was resolved. - Token scopes — carried by an
auth_tokensrecord, merged on top of the baseline when the platform is resolved via thetokenstrategy.
The effective scope set is platform.scopes ∪ token.scopes.
$platform->update(['scopes' => ['read']]);These scopes apply for every resolver (host, session, context, token).
Auth tokens are M2M (machine-to-machine) credentials stored in the auth_tokens table. Token scopes widen the platform's baseline — they cannot narrow it.
$platform->authTokens()->create([
'scopes' => ['read', 'write'],
]);platform()->can() returns true when the scope is present in the effective set (platform baseline + token scopes).
// In a controller, middleware, policy, etc.
if (! platform()->can('write')) {
abort(403);
}$token->hasScope('admin'); // bool
$token->scopes; // array<string>
$token->isExpired(); // bool
AuthToken::withScope('read')->get(); // query scopeSet expired_at to limit a token's lifetime. Expired tokens are ignored by the middleware resolver automatically.
$platform->authTokens()->create([
'scopes' => ['read'],
'expired_at' => now()->addDays(30),
]);// Store the current platform in the session (e.g. after an admin selects a platform)
platform()->saveToSession($platformModel);
// Or if it's already set:
platform()->set($model, 'admin');
platform()->saveToSession();
// Clear on logout / platform switch
platform()->clearFromSession();Switch platform for the duration of a callback, then restore the previous context automatically — even if the callback throws.
platform()->use($otherPlatform, function () {
// platform() resolves $otherPlatform here
Mail::send(...);
});
// platform() is restored hereUse the HasPlatformContext trait to capture and restore platform context across queue boundaries.
use mindtwo\LaravelPlatformManager\Jobs\Concerns\HasPlatformContext;
class ProcessOrder implements ShouldQueue
{
use HasPlatformContext;
public function __construct(private Order $order)
{
$this->capturePlatformContext(); // call at end of constructor
}
public function handle(): void
{
$this->restorePlatformContext(); // call at start of handle
// platform() is now resolved
}
}Publish the config and point platform.model at your own model:
// app/Models/Platform.php
use mindtwo\LaravelPlatformManager\Models\Platform as BasePlatform;
class Platform extends BasePlatform
{
// add columns, relationships, scopes ...
}// config/platform.php
'model' => \App\Models\Platform::class,Add the trait to any Eloquent model that belongs to a platform. It auto-fills platform_id on create and provides two query scopes.
use mindtwo\LaravelPlatformManager\Traits\BelongsToPlatform;
class Article extends Model
{
use BelongsToPlatform;
}
// Scopes
Article::forCurrentPlatform()->get();
Article::forPlatform($platform)->get();
Article::forPlatform(42)->get();The platform-scope middleware aborts with 403 if the resolved platform does not hold the required scope(s). Apply it after resolve-platform.
Route::middleware(['resolve-platform:token', 'platform-scope:write'])->group(function () {
// platform must have the 'write' scope
});
// Multiple scopes — all must be present
Route::middleware(['resolve-platform:token', 'platform-scope:read,write'])->group(function () {
// ...
});For models that belong to multiple platforms via a pivot table. Provides the same scopes as BelongsToPlatform but uses whereHas under the hood.
use mindtwo\LaravelPlatformManager\Traits\BelongsToManyPlatforms;
class Article extends Model
{
use BelongsToManyPlatforms;
}
// Scopes
Article::forCurrentPlatform()->get();
Article::forPlatform($platform)->get();
Article::forPlatform(42)->get();
// Relationship
$article->platforms; // Collection of Platform modelsThe pivot table is derived automatically as platform_{models} (e.g. platform_articles). Override getPlatformPivotTable() on the model to use a different name:
public function getPlatformPivotTable(): string
{
return 'article_platform';
}Platform settings are stored as JSON in the settings column and hydrated into a PlatformSettings DTO. Declare known properties as typed public fields and list any that should be encrypted at rest in $encrypted.
// app/Settings/PlatformSettings.php
use mindtwo\LaravelPlatformManager\Settings\PlatformSettings as BaseSettings;
class PlatformSettings extends BaseSettings
{
protected array $encrypted = ['apiSecret', 'smtpPassword'];
public ?string $appName = null;
public ?string $apiSecret = null; // encrypted at rest
public ?string $smtpPassword = null; // encrypted at rest
public ?string $billingPlan = null;
}Point the config at your class:
// config/platform.php
'settings' => \App\Settings\PlatformSettings::class,// Via the helper (dot notation, works for any depth)
platform()->setting('appName');
platform()->setting('mail.host', 'localhost'); // nested via overflow
// Via the model directly
$platform->settings->appName;
$platform->setting('appName');// Assign properties directly
$platform->settings->appName = 'My App';
$platform->settings->apiSecret = 's3cr3t'; // stored encrypted
$platform->save();
// Or replace the whole DTO
$platform->update(['settings' => ['appName' => 'My App', 'apiSecret' => 's3cr3t']]);Unknown keys (no matching declared property) are stored transparently in an overflow bag so existing data and config overrides continue to work without any changes.
A platform can override arbitrary Laravel config values by storing them under settings.config:
$platform->update([
'settings' => [
'config' => [
'mail.default' => 'ses',
'app.name' => 'My Platform',
],
],
]);These overrides are applied automatically whenever the platform is resolved.
All platform lookups go through PlatformRepository, which extends chiiya/laravel-utilities's AbstractRepository. The middleware resolves it automatically, but you can also inject it directly.
use mindtwo\LaravelPlatformManager\Repositories\PlatformRepository;
class PlatformController extends Controller
{
public function __construct(protected PlatformRepository $platforms) {}
}These map directly to the middleware strategies and read from the incoming request:
$repository->resolveByToken($request); // ?array{PlatformModel, array<string>}
$repository->resolveByHostname($request); // ?PlatformModel
$repository->resolveByContext($request); // ?PlatformModel
$repository->resolveBySession($request); // ?PlatformModelresolveByToken returns a tuple of [PlatformModel, effectiveScopes] where scopes are the platform baseline merged with the token's scopes.
$repository->findByHostname('example.com'); // ?PlatformModel
$repository->findByContext('my-context'); // ?PlatformModel
$repository->findByUuid('uuid-string'); // ?PlatformModel
$repository->findActiveById(1); // ?PlatformModel
// Returns [PlatformModel, effectiveScopes]|null
$repository->findByTokenWithScopes($rawToken);$repository->allActive(); // Collection<PlatformModel>
$repository->index(['is_active' => true]); // Collection<PlatformModel>
$repository->index(['hostname' => 'app.io']); // Collection<PlatformModel>
$repository->count(['is_active' => true]); // int
$repository->search('app', ['is_active' => true]); // LengthAwarePaginatorSupported applyFilters parameters: is_active, hostname, context.
Set a fake platform on the singleton without hitting the database. Useful in any test that exercises code which calls platform().
use mindtwo\LaravelPlatformManager\Testing\PlatformFake;
PlatformFake::make(['hostname' => 'test.com']);
// With resolver and scopes
PlatformFake::make(['hostname' => 'test.com'], resolver: 'token', scopes: ['read', 'write']);
// Reset back to unresolved
PlatformFake::reset();Add to your test case for a cleaner API and automatic teardown helpers:
use mindtwo\LaravelPlatformManager\Testing\InteractsWithPlatform;
class MyTest extends TestCase
{
use InteractsWithPlatform;
protected function tearDown(): void
{
$this->clearPlatform();
parent::tearDown();
}
public function test_something(): void
{
$this->setPlatform(['hostname' => 'test.com'], scopes: ['read']);
$this->assertPlatformResolved();
$this->assertPlatformCan('read');
$this->assertPlatformCannot('write');
$this->assertPlatformResolver('fake');
}
}| Method | Description |
|---|---|
setPlatform(array $attributes, string $resolver, array $scopes) |
Resolve a fake platform |
clearPlatform() |
Reset the singleton to unresolved |
assertPlatformResolved() |
Assert a platform is resolved |
assertPlatformNotResolved() |
Assert no platform is resolved |
assertPlatformCan(string $scope) |
Assert the platform has a scope |
assertPlatformCannot(string $scope) |
Assert the platform lacks a scope |
assertPlatformResolver(string $resolver) |
Assert the resolver name |
v4 is a full rewrite. Every section below is a breaking change — work through them in order.
The provider moved out of the Providers sub-namespace.
// Before (config/app.php or auto-discovery override)
mindtwo\LaravelPlatformManager\Providers\LaravelPlatformManagerProvider::class
// After
mindtwo\LaravelPlatformManager\LaravelPlatformManagerProvider::classThe config file was renamed from platform-resolver.php to platform.php, and the config key changed from platform-resolver to platform.
# Republish the config
php artisan vendor:publish --provider="mindtwo\LaravelPlatformManager\LaravelPlatformManagerProvider" --tag=configKey mapping:
// Before (config/platform-resolver.php)
'model' => Platform::class,
'headerNames' => [
AuthTokenTypeEnum::Public() => 'X-Context-Platform-Public-Auth-Token',
AuthTokenTypeEnum::Secret() => 'X-Context-Platform-Secret-Auth-Token',
],
'webhooks' => [ ... ],
// After (config/platform.php)
'model' => Platform::class,
'header_names' => [
'token' => 'X-Platform-Token',
],
'session_key' => 'platform_id',Update any config('platform-resolver.*') calls in your own code to config('platform.*').
Several columns were removed and three were added. Create a migration in your application:
Schema::table('platforms', function (Blueprint $table) {
// Remove v2-only columns (skip any you wish to keep in your own schema)
$table->dropColumn([
'owner_id',
'is_main',
'is_headless',
'name',
'default_locale',
'available_locales',
]);
// Widen hostname to 100 chars
$table->string('hostname', 100)->nullable()->change();
// Add new columns
$table->string('context')->nullable()->unique()->after('additional_hostnames');
$table->json('scopes')->nullable()->after('context');
$table->json('settings')->nullable()->after('scopes');
});The type column is replaced by scopes, expired_at is added, and several v2 columns are dropped:
Schema::table('auth_tokens', function (Blueprint $table) {
// Drop v2 columns
$table->dropForeign(['user_id']);
$table->dropUnique(['platform_id', 'token']); // composite unique
$table->dropColumn(['user_id', 'description']);
$table->dropSoftDeletes();
$table->dropColumn('type');
// Add v4 columns
$table->json('scopes')->default('[]')->after('platform_id');
$table->datetime('expired_at')->nullable()->after('token');
});Migrate existing token types to scopes before dropping type if you need to preserve access levels:
// Run before dropping 'type'
DB::table('auth_tokens')->where('type', 1)->update(['scopes' => '["read","write"]']); // Secret → full access
DB::table('auth_tokens')->where('type', 2)->update(['scopes' => '["read"]']); // Public → read onlyThe PlatformResolver service is gone. Replace all usages with the platform() helper or app(Platform::class).
// Before
app(PlatformResolver::class)->getCurrentPlatform()
resolve(PlatformResolver::class)->getCurrentPlatform()
// After
platform()->get()// Before — auth check
app(PlatformResolver::class)->checkAuth(AuthTokenTypeEnum::Secret())
// After — scope check
platform()->can('write')The old middleware classes are removed. Replace them with the new resolve-platform middleware.
// Before
\mindtwo\LaravelPlatformManager\Middleware\PlatformSession::class
// After
'resolve-platform:session'// Before — token-based routes
\mindtwo\LaravelPlatformManager\Middleware\ResolveBySecretToken::class
\mindtwo\LaravelPlatformManager\Middleware\ResolveByPublicToken::class
// After
'resolve-platform:token'Multiple strategies can be chained:
Route::middleware('resolve-platform:token|host|session')->group(...);Remove StatefulPlatformDomains — it is no longer part of this package.
Send the single X-Platform-Token header instead of the separate public/secret headers:
# Before
X-Context-Platform-Secret-Auth-Token: <token>
X-Context-Platform-Public-Auth-Token: <token>
# After
X-Platform-Token: <token>
The full webhook system (tables, jobs, routes, Nova resources) has been removed. If your application used webhooks:
- Drop the
webhooksandwebhook_requeststables - Remove any references to
PushToWebhook,WebhookController,WebhookConfiguration,WebhookRequest,EnsureWebhooksAreEnabled - Remove the
platform-resolver.webhooksconfig section (gone with step 2)
All built-in Nova resources have been removed. If you extended them, copy the field definitions into your own resource classes.
v4 is a breaking release. The changes below are required.
The type column (Public/Secret) has been replaced with a scopes JSON array.
Migration — if you published the migration previously, update your create_auth_tokens_table migration (or create a new migration on existing tables):
// Before
$table->smallInteger('type');
// After
$table->json('scopes')->default('[]');For existing tables, create a new migration:
Schema::table('auth_tokens', function (Blueprint $table) {
$table->json('scopes')->default('[]')->after('platform_id');
$table->dropColumn('type');
});Code — replace all AuthTokenTypeEnum references:
// Before
$token->type = AuthTokenTypeEnum::Secret;
$token->type = AuthTokenTypeEnum::Public;
// After — just assign scopes
$token->scopes = ['read', 'write'];// Before
'header_names' => [
'public' => 'X-Context-Platform-Public-Auth-Token',
'secret' => 'X-Context-Platform-Secret-Auth-Token',
],
// After
'header_names' => [
'token' => 'X-Platform-Token',
],Update any API clients to send X-Platform-Token (or whatever you configure) instead of the old public/secret headers.
// Before
Route::middleware('resolve-platform:public-token|secret-token|host')->group(...);
// After
Route::middleware('resolve-platform:token|host')->group(...);// Before
Platform::query()->byPublicAuthToken($token)->first();
Platform::query()->bySecretAuthToken($token)->first();
// After — single scope, expiry checked automatically
Platform::query()->byToken($token)->first();Delete any imports or references to mindtwo\LaravelPlatformManager\Enums\AuthTokenTypeEnum. The enum no longer exists.
The built-in AuthToken Nova resource has been removed. If you extended it, update your subclass to work without the base class or reimplement the fields directly. The scopes field is a JSON array — a Tag or Text field works well.
Scope authorization is now available for all resolvers, not just token:
if (platform()->can('write')) {
// scope is in platform baseline or widened by the resolved token
}Platform baseline scopes (platforms.scopes) apply for every resolver. Token scopes are additive on top.
Please see CHANGELOG for more information on what has changed recently.
Please see CONTRIBUTING for details.
If you discover any security related issues, please email info@mindtwo.de instead of using the issue tracker.
The MIT License (MIT). Please see License File for more information.