whilesmart/eloquent-workspaces
A Laravel package that adds workspace awareness to Eloquent models. Associate records with the active workspace and scope queries through simple traits, making team-based or multi-tenant applications easier to build without repeating boilerplate.
1. Install and publish
composer require whilesmart/eloquent-workspaces
php artisan vendor:publish --provider="Whilesmart\EloquentWorkspaces\WorkspaceServiceProvider"
php artisan migrate
Publishing is not optional here — you almost always need to edit the migrations (add tenant_id, billing columns, custom slug rules) and the config before going live. Check config/workspaces.php first; it is the map of every convention the package assumes (model classes, table names, the "current workspace" resolver, role names).
2. Wire the traits onto your models
// app/Models/User.php
use Whilesmart\EloquentWorkspaces\Concerns\HasWorkspaces;
class User extends Authenticatable
{
use HasWorkspaces;
}
// app/Models/Project.php — anything that lives inside a workspace
use Whilesmart\EloquentWorkspaces\Concerns\BelongsToWorkspace;
Then confirm the Workspace and membership models in app/Models (or in the package namespace, depending on your config) match what you need. If the package expects workspace_user and you have memberships, change it in config, not in the migration after the fact.
3. First use case: a user owns a workspace and one scoped record
$workspace = Workspace::create(['name' => 'Acme', 'slug' => 'acme']);
$workspace->members()->attach($user->id, ['role' => 'owner']);
// Create a workspace-scoped record
$workspace->projects()->create(['name' => 'Launch']);
// Resolve the current workspace for the request
app(\Whilesmart\EloquentWorkspaces\CurrentWorkspace::class)->set($workspace);
Project::all(); // returns only Acme's projects
If that loop works in php artisan tinker, the package is installed correctly. Everything after this is scoping correctness.
Where to look first: the config file, then the CurrentWorkspace/resolver class, then the BelongsToWorkspace trait. Those three files define 90% of the behaviour you'll be debugging.
Middleware-driven current workspace
The reliable pattern is a middleware that resolves the workspace from the route (or session) and binds it into the container before any controller code runs:
public function handle($request, Closure $next)
{
$workspace = $request->user()
?->workspaces()
->where('slug', $request->route('workspace'))
->firstOrFail();
app(CurrentWorkspace::class)->set($workspace);
return $next($request);
}
Register it after auth and apply it to a route group rather than globally — you don't want it firing on webhook or health-check routes. Use route model binding (/w/{workspace}/projects/{project}) and in the Project route binding constraint add ->where('workspace_id', current_workspace()->id) so a user can't pull a project out of another workspace by guessing an ID.
Switching workspaces in the UI
Keep switching as an explicit POST that writes the chosen workspace id to the session, then redirects. Do not resolve "current workspace" from a query string — it makes every page cacheable-by-nobody and invites IDOR bugs. A simple WorkspaceSwitcher Livewire/controller action plus a EnsureWorkspaceSelected middleware handles the multi-workspace case cleanly.
Roles on the pivot
Put role checks behind policies, not controllers, so they compose:
public function update(User $user, Project $project): bool
{
return $user->hasRoleIn($project->workspace, ['owner', 'admin']);
}
If the package only ships owner/member, add a roles array in config and a small WorkspaceRole enum rather than sprinkling string comparisons. Role changes should go through a single WorkspaceMembership service so you can audit them (membership.role_changed event) — this is the thing you'll regret skipping once customers ask "who removed me?".
Invitations
The common shape: an invitations table with workspace_id, email, role, token, expires_at, plus an accept route that is not behind workspace middleware. Accepting should be idempotent (a user clicking the email twice shouldn't double-attach), and should fail loudly if the invite email doesn't match the authenticated user. Check whether the package provides this; if not, build it beside the package rather than inside it.
Scoping jobs and artisan commands
Every queued job that touches scoped models must carry workspace_id and re-establish context in handle():
public function __construct(public int $workspaceId) {}
public function handle(): void
{
app(CurrentWorkspace::class)->set(Workspace::findOrFail($this->workspaceId));
// now scoped queries work
}
Prefer passing the workspace id over passing the model — serialized models go stale and re-resolve outside context.
Caching
Prefix cache keys with the workspace id ("ws:{$id}:projects:list") and flush on membership change. Workspace-scoped caches that aren't keyed by workspace are the single most common data-leak source in multi-tenant apps.
Testing
Add a test helper that builds an authenticated user + workspace and sets the current workspace in setUp(). Without it, half your tests will pass trivially because the global scope is returning nothing.
withoutGlobalScopes() is a loaded gun. It's occasionally correct (admin dashboards, cross-workspace reports) and usually a security bug. Grep for it in code review; prefer a dedicated Workspace::withoutScoping() helper or a crossWorkspace() query macro so the escape hatch is visible and rare.scoped(), not singleton(), and that the middleware resets it at the start of every request.belongsTo from Project to Comment won't scope by workspace unless Comment also uses the trait. Add workspace_id to every child table and use the trait on every child model, even when the foreign key "implies" the workspace.workspace_id. unique('slug') will collide the moment two customers both want "marketing". Use unique(['workspace_id', 'slug']), and generate slugs with a per-workspace suffix fallback.onDelete('cascade') on every workspace_id FK before your first real customer deletes a workspace.deleted_at user still on the pivot will pass members() checks. Filter active users explicitly in member listings and role guards.AppServiceProvider::register or by editing the published config), and changing the table name in config after migrating will break pivots referencing the old name. Decide table names once, on day one.workspace() relationship method are the usual three seams. Overriding the resolver (e.g. to use a domain instead of a slug) is much cheaper than forking the package.dd(app(CurrentWorkspace::class)->get()) at the top of a failing controller tells you instantly whether it's a scoping problem or a logic problem. Add DB::listen() temporarily if you need to see the injected where workspace_id = ? predicate.$workspace->members inside a @foreach over workspaces is a classic N+1; with('members') fixes it. This package's data shape makes that loop very easy to write badly.How can I help you explore Laravel packages today?