Search by

raqmeyat / workspace

loai-K

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.

v0.1.1 2026-10-25 21:59 UTC

This package is auto-updated.

Last update: 2026-10-25 22:00:30 UTC


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, or auto (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 WorkspaceRole enum (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 to config('workspace.models.workspace')
  • multitenancy.tenant_finder → points to WorkspaceTenantFinder::class
  • multitenancy.tenant_table_name → points to workspaces (or customized table)
  • multitenancy.tenant_artisan_search_fields → includes ['id', 'slug', 'subdomain', 'name'] (allows running php artisan tenants:artisan --tenant=acme)
  • multitenancy.landlord_database_connection_name → routed to all landlord models (Workspace, WorkspaceDomain, WorkspaceSetting, WorkspacePlan, WorkspaceSubscription, WorkspaceStaff) via UsesLandlordConnection.

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:

  1. Scope all queries to the currently active tenant (Workspace::current()).
  2. Automatically assign workspace_id when creating new model instances under an active workspace.
  3. Provide the $project->workspace relationship.
  4. Allow bypassing scoping when needed: Project::withoutWorkspace()->get() or Project::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):
    • CreateWorkspaceData
    • SubscribeToPlanData
    • InviteStaffData
    • UpdateWorkspaceSettingsData
    • CreatePlanData
    • TransferOwnershipData
    • ChangeStaffRoleData
  • Actions (src/Actions): Single use-case orchestrators handling DB transactions, cross-service calls, mailings, and event dispatching:
    • CreateWorkspaceAction
    • SubscribeWorkspaceToPlanAction
    • CancelWorkspaceSubscriptionAction
    • InviteWorkspaceStaffAction
    • AcceptStaffInvitationAction
    • ChangeStaffRoleAction
    • RemoveWorkspaceStaffAction
    • TransferWorkspaceOwnershipAction
    • UpdateWorkspaceSettingsAction
    • CreatePlanAction
  • Services (src/Services): Low-level domain operations directly managing Eloquent models, validation invariants, and queries:
    • WorkspaceService
    • WorkspaceStaffService
    • SubscriptionService
    • PlanService
    • WorkspaceSettingsService
  • 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 User model uses HasUuids to link via foreignUuid or traditional foreignId.
  • Reusable Trait: The package provides Raqmeyat\Workspace\Concerns\HasUuid7 for 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.