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 Owner Access Laravel Package

whilesmart/eloquent-owner-access

A lightweight Laravel package that adds owner and access-control behavior to Eloquent models, letting you assign owners and authorize who can view, edit, or share records with simple, reusable traits.

View on GitHub
Deep Wiki
Context7

Getting Started

The package solves one narrow problem: models that belong to someone (owner_type / owner_id) need authorization, but the package owning those models shouldn't care whether "someone" is a User, Team, Tenant, or Organization. The host app supplies that decision via a contract.

Install and publish

composer require whilesmart/eloquent-owner-access
php artisan vendor:publish --tag="eloquent-owner-access-config"

Read config/eloquent-owner-access.php first — it's the shortest path to understanding what's pluggable (resolver binding, morph map usage, enforcement mode, and which models opt into scoping). If the config file is thin, jump straight to the service provider and the contract interface; that pair defines the entire extension surface.

Wire the resolver in your host app

The core integration is a single binding in AppServiceProvider:

use Whilesmart\OwnerAccess\Contracts\ResolvesOwner;

public function register(): void
{
    $this->app->bind(ResolvesOwner::class, function ($app) {
        return new \App\Authorization\RequestOwnerResolver();
    });
}

Your resolver answers "who is acting right now?" — usually auth()->user(), but for a team-scoped app it returns the active team, and for an API it may read a token claim.

First use case: an owner-scoped Invoice

Add the package trait to any model with owner_type/owner_id columns:

use Whilesmart\OwnerAccess\Concerns\HasOwner;

class Invoice extends Model
{
    use HasOwner;
}

Then in a controller, scope the query rather than hand-rolling where('owner_id', ...):

public function index()
{
    return Invoice::ownedByCurrentOwner()->paginate();
}

That's the whole loop: trait on the model, resolver in the container, scope on the query, and an authorization check before writes. Get that working on one model before rolling it across the app.

Implementation Patterns

Stamp the owner on create, don't trust the payload

The most common failure mode in owner-scoped systems is letting a client POST owner_id. Use a model event so the owner is always server-derived:

protected static function booted(): void
{
    static::creating(function (Invoice $invoice) {
        $invoice->owner()->associate(app(ResolvesOwner::class)->resolve());
    });
}

If the package already does this via the trait, verify it in tests — a stale owner_id from a mass-assignment payload is a silent privilege escalation.

Two audiences: consuming packages vs. host apps

Keep the separation clean in your head:

  • Package authors depend on the trait + authorization checks and never reference App\Models\User. They call the contract.
  • Host apps implement the contract once and register it. Swapping from user-tenancy to team-tenancy becomes a one-class change.

If you maintain both, write the host-side resolver test first — it's the seam where tenancy bugs live.

Authorize before mutating, not just before querying

Scoping reads is table stakes; the interesting bugs are writes. Wrap mutations in an explicit check:

Gate::define('update', function (User $user, Invoice $invoice) {
    return $invoice->isOwnedBy(app(ResolvesOwner::class)->resolve())
        || $user->isAdmin();
});

Then $this->authorize('update', $invoice) in the controller. The admin escape hatch belongs in the policy, not in the global scope — keep scoping and privilege orthogonal.

Compose with policies for the "can see vs. can modify" split

A read-only owner record (a shared report, a locked period) is common. Model it as a policy method pair rather than baking a readonly flag into the scope:

public function view(User $user, Invoice $invoice): bool { ... }
public function update(User $user, Invoice $invoice): bool { ... }

Explicit scopes over global scopes for host-app models

Global scopes are convenient until an admin dashboard, an export job, or a webhook handler silently gets filtered results. Prefer applying ownedByCurrentOwner() at the query builder in tenant-facing controllers, and reserve global scoping (if the package offers it) for genuinely single-tenant surfaces.

Queue jobs and CLI need an explicit owner context

Any job that creates or queries owner-scoped records must carry the owner identity, because auth() is empty in a worker:

final class GenerateMonthlyStatement implements ShouldQueue
{
    public function __construct(
        public readonly string $ownerType,
        public readonly int|string $ownerId,
    ) {}

    public function handle(ResolvesOwner $resolver): void
    {
        $resolver->setOwner(Owner::fromKey($this->ownerType, $this->ownerId));
        // ...
    }
}

Serialize the owner as a key, not as the model — a SerializesModels owner will be re-fetched correctly, but only if you re-establish it on the resolver in handle().

Testing pattern

Swap the resolver binding in the test harness rather than faking auth:

$this->app->instance(ResolvesOwner::class, new FixedOwnerResolver($team));

That lets you test the "no owner" and "wrong owner" branches without gymnastics through actingAs().

Gotchas and Tips

Morph map stability is the whole ballgame. owner_type stores a string. The moment you rename App\Models\Team to App\Models\Workspace, every historical row breaks unless you registered a morph map before writing data:

Relation::enforceMorphMap([
    'user' => \App\Models\User::class,
    'team' => \App\Models\Team::class,
]);

Do this at project inception. Retrofitting means a data migration across every owner-scoped table.

Composite index or slow queries. Add index(['owner_type', 'owner_id']) on every table with those columns. Ownership lists are almost always "all X for this owner, newest first," so index(['owner_type', 'owner_id', 'created_at']) is often the better single index.

Null owners and system records. Decide early whether owner_id is nullable (seeded defaults, system-generated records). If it is, your resolver must return null cleanly and your scope must decide whether null-owned rows are visible to everyone or no one. Ambiguity here produces the classic "seeded data vanished in production but not staging" bug.

Orphaned records after owner deletion. A User deletion leaves owner-scoped rows pointing at nothing. Either cascadeOnDelete at the FK level (only possible if you can FK the polymorphic pair — usually you can't) or add a cleanup job. Unscoped queries will happily return orphaned rows to nobody.

Owner transfer is a privileged operation. Reassigning owner_id must be gated separately from update. A user who can edit an invoice's line items shouldn't be able to hand it to another team. Add an explicit transfer policy method and log it.

Resolver calls on hot paths. If ResolvesOwner does a DB lookup (e.g., "active team from membership"), memoize per request/instance. Laravel's container is a singleton per request in the default lifecycle, so a cached property on the resolver is usually sufficient — but under Octane, be certain you're not caching across requests.

Global-scope surprises in admin panels. If the package registers a global scope, your admin tooling (Nova, Filament, custom dashboards) needs withoutGlobalScope(...) or an explicit super-admin bypass in the scope itself. Test the admin path explicitly; it's the one everyone forgets.

Package is young and lightly adopted (0 stars, no dependents). Pin the version ("whilesmart/eloquent-owner-access": "^1.0" or exact) and read the source before upgrading — a 0.x release can change a contract signature without much ceremony. Budget a day to read the trait, the scope, and the resolver contract end to end; it's a small surface, and you'll know more than the docs do afterwards.

Check what the trait assumes about your schema. Confirm the column names are literally owner_type/owner_id (some packages make them configurable) and whether the trait expects the owner relation to be named owner(). If you already have an owner() relation defined differently, you'll get a collision at the least convenient time — rename or override deliberately.

Extension point to prefer. If the default scope or authorization behavior doesn't fit, bind a custom implementation of the package's contract rather than forking. The package's value proposition is the swappable seam; forking it forfeits that.

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.
socialdept/atp-orm
cboxdk/laravel-assistant-client
socialdept/atp-resolver
socialdept/tenant-domains
tautvydasr/blade-class-names
socialdept/color-scales
maherelgamil/controller
coleus/widgets
coleus/table
coleus/users
coleus/music
coleus/health
coleus/calendar
coleus/notes
coleus/support
coleus/settings
coleus/apps
seampact/laravel
emmyconceptng/laravel-ng-postcode
jjsquady/round-robin