raqmeyat / workspace
Multi-tenant Workspace management for Laravel 13, built on spatie/laravel-multitenancy. Path, subdomain, and custom-domain tenancy, workspace settings, plans & subscriptions, and role-based staff management.
Requires
- php: ^8.3
- laravel/framework: ^13.0
- spatie/laravel-multitenancy: ^4.2
- spatie/laravel-package-tools: ^1.93
Requires (Dev)
- laravel/pint: ^1.24
- orchestra/testbench: ^11.0
- pestphp/pest: ^4.0|^5.0
- pestphp/pest-plugin-laravel: ^4.0|^5.0
Suggests
None
Provides
None
Conflicts
None
Replaces
None
README
Multi-tenant Workspace package for Laravel 13 (PHP 8.3+), built on top of
spatie/laravel-multitenancy.
It adds the SaaS-shaped pieces multitenancy alone doesn't give you:
- Flexible tenant resolution — path (
/w/{slug}), subdomain ({slug}.yourapp.com), custom domain, orauto(domain → subdomain → path, first match wins). - Workspace Settings — a typed, per-workspace key/value store with a denormalised read cache.
- Plans & Subscriptions — sellable plan catalog, trial/grace-period/ cancellation lifecycle, price snapshotting at purchase time.
- Staff & Roles — invite-by-email membership with a ranked
WorkspaceRoleenum (Member < Manager < Admin < Owner).
Built to the layering discipline: FormRequest → DTO → Action → Service → Model. Actions never touch Eloquent directly; only Services do.
Installation
composer require raqmeyat/workspace php artisan vendor:publish --tag="workspace-config" php artisan vendor:publish --tag="workspace-migrations" php artisan migrate
Spatie Multitenancy Customization & Landlord Migration
If you are using spatie/laravel-multitenancy directly or running in a multi-database architecture (landlord + tenant databases), raqmeyat/workspace provides a turnkey integration:
# 1. Publish customized Spatie config (bridges Workspace tenant finder, search fields, & model) php artisan vendor:publish --tag="workspace-multitenancy-config" # 2. Publish customized landlord tenants table migration (UUIDv7, slug, domain, settings, status) php artisan vendor:publish --tag="workspace-landlord-migrations" # 3. Run landlord migrations php artisan migrate --path=database/migrations/landlord
Configuration
Everything lives in config/workspace.php and config/multitenancy.php. The essentials:
WORKSPACE_RESOLVER=auto # path | subdomain | domain | auto WORKSPACE_CENTRAL_DOMAIN=yourapp.com WORKSPACE_PATH_PREFIX=w WORKSPACE_USER_MODEL=App\Models\User WORKSPACE_TRIAL_DAYS=14 WORKSPACE_SUBSCRIPTION_GRACE_DAYS=3 # Multi-database architectures (optional) LANDLORD_DB_CONNECTION=landlord TENANT_DB_CONNECTION=tenant
Every model (Workspace, WorkspaceDomain, WorkspaceSetting,
WorkspacePlan, WorkspaceSubscription, WorkspaceStaff) can be swapped
for your own subclass via config('workspace.models.*') without forking
the package.
Spatie Multitenancy Automatic Bridge
The service provider automatically synchronizes:
multitenancy.tenant_model→ points toconfig('workspace.models.workspace')multitenancy.tenant_finder→ points toWorkspaceTenantFinder::classmultitenancy.tenant_table_name→ points toworkspaces(or customized table)multitenancy.tenant_artisan_search_fields→ includes['id', 'slug', 'subdomain', 'name'](allows runningphp artisan tenants:artisan --tenant=acme)multitenancy.landlord_database_connection_name→ routed to all landlord models (Workspace,WorkspaceDomain,WorkspaceSetting,WorkspacePlan,WorkspaceSubscription,WorkspaceStaff) viaUsesLandlordConnection.
Quick start
1. Create a workspace
use Raqmeyat\Workspace\Actions\CreateWorkspaceAction; use Raqmeyat\Workspace\Data\CreateWorkspaceData; $workspace = app(CreateWorkspaceAction::class)->handle( CreateWorkspaceData::fromArray([ 'name' => 'Acme Tours', 'slug' => 'acme-tours', 'owner_id' => $request->user()->id, 'custom_domain' => 'booking.acme-tours.com', // optional ]) );
This seats the creator as WorkspaceRole::Owner, seeds default settings,
and activates the workspace.
2. Resolve the current tenant in a request
use Raqmeyat\Workspace\Facades\Workspace; $workspace = Workspace::current(); // null on the central/marketing app
Guard routes with the bundled middleware aliases:
Route::middleware(['web', 'workspace.servable', 'workspace.subscribed']) ->group(function () { Route::get('/dashboard', DashboardController::class) ->middleware('workspace.role:manager'); });
workspace.subscribed checks the subscription's deadlines (trial end, period
end plus grace_period_days, grace-period end) on every request, so access
lapses on time even if your scheduler hasn't transitioned the status yet. A
workspace that has never subscribed passes while it is inside the default
trial it was created with.
Resolution caching
Tenant lookups are cached per identifier (custom domain, subdomain, or path
slug) for workspace.cache.ttl_seconds, so a warm request resolves its
workspace without a database query. Saving or deleting a Workspace or
WorkspaceDomain through Eloquent invalidates the affected entries
immediately, so suspensions, renames and domain changes take effect on the
next request. Writes that bypass model events (Workspace::query()->update())
are picked up when the TTL expires. Set the TTL to 0 to disable caching.
3. Workspace settings
use Raqmeyat\Workspace\Services\WorkspaceSettingsService; $settings = app(WorkspaceSettingsService::class); $settings->set($workspace, 'timezone', 'Asia/Riyadh'); $settings->get($workspace, 'timezone'); // 'Asia/Riyadh' $settings->all($workspace); // array<string, mixed>
4. Plans & subscriptions
use Raqmeyat\Workspace\Services\PlanService; use Raqmeyat\Workspace\Enums\PlanInterval; use Raqmeyat\Workspace\Actions\SubscribeWorkspaceToPlanAction; use Raqmeyat\Workspace\Data\SubscribeToPlanData; $plan = app(PlanService::class)->create( name: 'Growth', code: 'growth', price: 49.00, interval: PlanInterval::Monthly, staffSeatLimit: 10, features: ['api_access' => true, 'sms_notifications' => true], ); app(SubscribeWorkspaceToPlanAction::class)->handle( SubscribeToPlanData::make($workspace->id, $plan->id) );
5. Staff & roles
use Raqmeyat\Workspace\Actions\InviteWorkspaceStaffAction; use Raqmeyat\Workspace\Data\InviteStaffData; use Raqmeyat\Workspace\Enums\WorkspaceRole; app(InviteWorkspaceStaffAction::class)->handle( InviteStaffData::fromArray($workspace->id, [ 'email' => 'ops@acme-tours.com', 'role' => WorkspaceRole::Manager->value, ], invitedByUserId: $request->user()->id) );
Seat limits are enforced automatically from the workspace's active plan
(WorkspacePlan::staff_seat_limit) or a per-workspace staff_seat_limit
setting override, whichever is set; null means unlimited.
Custom domains
use Raqmeyat\Workspace\Services\WorkspaceService; $domain = app(WorkspaceService::class)->addDomain($workspace, 'booking.acme-tours.com', primary: true); // after DNS/SSL verification succeeds on your side: app(WorkspaceService::class)->verifyDomain($domain);
Only verified_at-stamped domains resolve — an unverified custom domain
can never accidentally serve tenant traffic.
Artisan scheduling (subscription transitions)
The package does not register its own scheduled task, since grace-period
and expiry timing is business-specific. Wire it into your own
routes/console.php:
use Illuminate\Support\Facades\Schedule; use Raqmeyat\Workspace\Models\WorkspaceSubscription; use Raqmeyat\Workspace\Services\SubscriptionService; Schedule::call(function (SubscriptionService $service) { WorkspaceSubscription::query() ->where('status', \Raqmeyat\Workspace\Enums\SubscriptionStatus::Active) ->where('current_period_ends_at', '<', now()) ->each(fn ($subscription) => $service->enterGracePeriod($subscription)); WorkspaceSubscription::query() ->where('status', \Raqmeyat\Workspace\Enums\SubscriptionStatus::GracePeriod) ->where('grace_period_ends_at', '<', now()) ->each(fn ($subscription) => $service->expire($subscription)); })->daily();
Scoping Models to Workspaces
Use the BelongsToWorkspace trait on any application models that belong to a single tenant (e.g. Invoice, Project, Document):
namespace App\Models; use Illuminate\Database\Eloquent\Model; use Raqmeyat\Workspace\Concerns\BelongsToWorkspace; class Project extends Model { use BelongsToWorkspace; protected $guarded = []; }
This will automatically:
- Scope all queries to the currently active tenant (
Workspace::current()). - Automatically assign
workspace_idwhen creating new model instances under an active workspace. - Provide the
$project->workspacerelationship. - Allow bypassing scoping when needed:
Project::withoutWorkspace()->get()orProject::forWorkspace($workspace)->get().
Workspace Model Helpers
The Workspace model provides ergonomic shortcuts:
// Tenancy URL generation (auto-selects custom domain, subdomain, or path) $url = $workspace->url('/dashboard'); // Settings $timezone = $workspace->setting('timezone', 'UTC'); $workspace->setSetting('brand_color', '#10b981'); $workspace->setSettings(['key' => 'val', 'locale' => 'ar']); // Plans & features $plan = $workspace->plan(); $hasSub = $workspace->hasActiveSubscription(); // a subscription currently grants access $allowed = $workspace->hasValidAccess(); // ...or still inside the default trial $canExport = $workspace->canUseFeature('export_csv'); // Staff & roles $isStaff = $workspace->hasStaff($user); $role = $workspace->roleOf($user); // WorkspaceRole::Admin $membership = $workspace->membershipOf($user); // ?WorkspaceStaff (joined members only) // Relations $owner = $workspace->owner; // config('workspace.user_model') $inviter = $staff->invitedBy; // Query scopes Workspace::servable()->get(); WorkspacePlan::active()->orderBy('price')->get(); WorkspaceDomain::verified()->primary()->get(); WorkspaceStaff::joined()->count(); // ::pending() for open invitations WorkspaceSubscription::grantingAccess()->get(); // Spatie Multitenancy multi-database compatibility $databaseName = $workspace->getDatabaseName();
Architecture & Layering Discipline
The package strictly adheres to domain-driven layering:
FormRequest / Input → Data (DTO) → Action → Service → Model
- Data (
src/Data): Immutable, typed DTOs carrying inputs with factory helpers (fromArray,fromRequest,toArray):CreateWorkspaceDataSubscribeToPlanDataInviteStaffDataUpdateWorkspaceSettingsDataCreatePlanDataTransferOwnershipDataChangeStaffRoleData
- Actions (
src/Actions): Single use-case orchestrators handling DB transactions, cross-service calls, mailings, and event dispatching:CreateWorkspaceActionSubscribeWorkspaceToPlanActionCancelWorkspaceSubscriptionActionInviteWorkspaceStaffActionAcceptStaffInvitationActionChangeStaffRoleActionRemoveWorkspaceStaffActionTransferWorkspaceOwnershipActionUpdateWorkspaceSettingsActionCreatePlanAction
- Services (
src/Services): Low-level domain operations directly managing Eloquent models, validation invariants, and queries:WorkspaceServiceWorkspaceStaffServiceSubscriptionServicePlanServiceWorkspaceSettingsService
- Enums (
src/Enums): Backed, strongly-typed domain state machines and role rank calculations:WorkspaceStatus(Pending,Active,Suspended,Archived)WorkspaceRole(Member,Manager,Admin,Owner)SubscriptionStatus(Trialing,Active,PastDue,GracePeriod,Canceled,Expired)PlanInterval(Monthly,Quarterly,Yearly)TenancyResolverMode(Domain,Subdomain,Path,Auto)
Domain Events
The package dispatches standard Laravel events for every major lifecycle change:
| Event | Dispatched When | Payload |
|---|---|---|
WorkspaceCreated |
A workspace is created and activated | public Workspace $workspace |
WorkspaceActivated |
A workspace transitions to Active | public Workspace $workspace |
WorkspaceSuspended |
A workspace transitions to Suspended | public Workspace $workspace |
WorkspaceArchived |
A workspace transitions to Archived | public Workspace $workspace |
WorkspaceOwnershipTransferred |
Workspace owner changes | `public Workspace $workspace, public int |
StaffInvited |
An invitation email is sent | public WorkspaceStaff $staff |
StaffInvitationAccepted |
An invited user joins the workspace | public WorkspaceStaff $staff |
StaffRemoved |
A staff member is removed | public WorkspaceStaff $staff |
StaffRoleChanged |
A staff member's role is updated | public WorkspaceStaff $staff, public WorkspaceRole $oldRole, public WorkspaceRole $newRole |
WorkspaceSubscribed |
A workspace subscribes to a plan | public WorkspaceSubscription $subscription |
WorkspaceSubscriptionCanceled |
An active subscription is canceled | public WorkspaceSubscription $subscription |
UUIDv7 Primary & Foreign Keys
All package tables (workspaces, workspace_domains, workspace_settings, workspace_plans, workspace_subscriptions, workspace_staff) use UUIDv7 (Str::uuid7()) primary keys:
- Time-Ordered: High 48 bits encode a Unix millisecond timestamp, providing natural chronological sorting (
$w1->id < $w2->id). - High-Performance B-Trees: Eliminates index fragmentation and page-splits typical of random UUIDv4, matching the write speed of auto-incrementing integers.
- Universal User Model Compatibility: Migrations automatically detect whether your
Usermodel usesHasUuidsto link viaforeignUuidor traditionalforeignId. - Reusable Trait: The package provides
Raqmeyat\Workspace\Concerns\HasUuid7for any tenant-scoped application models.
use Illuminate\Database\Eloquent\Model; use Raqmeyat\Workspace\Concerns\BelongsToWorkspace; use Raqmeyat\Workspace\Concerns\HasUuid7; class Project extends Model { use HasUuid7; use BelongsToWorkspace; }
Testing
composer test # Pest composer lint # Pint, check only composer format # Pint, apply fixes
License
MIT.