Weave Code
Weaver
Helps Laravel developers discover, compare, and choose open-source packages. See popularity, security, maintainers, and scores at a glance to make better decisions.
Feedback
Share your thoughts, report bugs, or suggest improvements.
Subject
Message

Eloquent Workspaces Laravel Package

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.

View on GitHub
Deep Wiki
Context7

Getting Started

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.

Implementation Patterns

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.

Gotchas and Tips

  • Global scopes silently return zero rows outside a request. Console commands, queued jobs, notifications, and Nova/Telescope all run with no current workspace. You'll see empty collections, not errors. If a query "returns nothing" in tinker but works in the browser, this is why.
  • 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.
  • The current workspace is request state, not app state. In Octane or long-running workers, a container singleton holding a workspace can leak between requests. Verify the binding is scoped(), not singleton(), and that the middleware resets it at the start of every request.
  • Relationships need the scope too. A 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.
  • Unique constraints must include 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.
  • Deleting a workspace is a cascade problem. The package migration may not cascade on your custom tables. Audit onDelete('cascade') on every workspace_id FK before your first real customer deletes a workspace.
  • Membership checks and soft deletes interact badly. A deleted_at user still on the pivot will pass members() checks. Filter active users explicitly in member listings and role guards.
  • Config quirks: model class overrides in config usually must be set before the service provider boots (i.e. in 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.
  • Extension points worth knowing: the workspace resolver class, the membership model, and the trait's 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.
  • Debugging: 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.
  • Eager-load the workspace on member lists. $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.
  • Low adoption means you are the QA. With zero dependents and a future-dated release, treat every non-trivial path (invitations, role changes, deletion, domain-based resolution) as unverified until you've written a test for it. Keep the package behind a thin internal service/interface so that if you need to swap or fork it, you change one class rather than two hundred call sites.
Weaver

How can I help you explore Laravel packages today?

Conversation history is not saved when not logged in.
Prompt
Add packages to context
No packages found.
mrnewport/laravel-plaid
mrnewport/laravel-repo
mrnewport/laravel-docsign
mrnewport/laravel-stow
mrnewport/laravel-flow
mrnewport/laravel-priceable
mrnewport/laravel-nestedterms
fredmeng/laravel-apidoc-generator
itstructure/laravel-multilingual-tools
pdustdar/elasticsearch
markupai/php-sdk
rlacerda83/lumem-microservice-shipment
uni-method/symfony-lib
tacticmedia/scim-server-bundle
neika-ai/symfony-bundle
mattleads/agent-publishing-bundle
glavweb/framework-bundle
glavweb/extra-bundle
glavweb/api-bundle
glavweb/admin-bundle