vantage/authorized-attributes
A Laravel package for attribute-level authorization: control which model attributes users can view or update using policies and PHP attributes, keeping sensitive fields protected across requests and resources.
The package adds attribute-level authorization to Eloquent models: instead of only deciding whether a user can update a User, you decide which fields of that User they can read or write. That is the whole value proposition, and it maps to two recurring problems in Laravel apps: privilege escalation through mass assignment (role, is_admin, tenant_id, balance), and over-sharing in API responses.
Minimal setup:
composer require vantage/authorized-attributes
Then:
php artisan vendor:publish --tag=...). The important question to answer immediately is the default posture: is an attribute that has no explicit authorization rule allowed or denied? Set this to fail-closed before you write any rules.User model, since it's the one with the dangerous columns.role, is_admin, or email_verified_at. That single screen is the fastest way to validate whether the package's ergonomics fit your codebase.If step 4 doesn't feel natural, the package probably isn't worth the dependency — the alternative is a $fillable + $guarded discipline plus a Form Request, which is what most teams do.
Trait + declarative rules on the model. The primary pattern is defining which attributes are authorized in one place on the model, next to the other model concerns, rather than scattering $request->only([...]) across controllers. This is the biggest day-to-day win: a new sensitive column added six months from now has one obvious place to be guarded.
class User extends Authenticatable
{
use AuthorizedAttributes;
// illustrative — check the README for the real shape
protected function authorizedAttributes(): array
{
return [
'name' => ['view' => true, 'update' => true],
'email' => ['view' => true, 'update' => true],
'role' => ['view' => false, 'update' => $this->can('assignRole')],
'is_admin' => ['view' => false, 'update' => false],
'tenant_id' => ['view' => false, 'update' => false],
];
}
}
Filter input at the controller boundary. The most valuable integration is a single call that takes validated input and strips unauthorized keys before fill()/update(). Do this before mass assignment, not after — filtering after the write is meaningless.
$user->update($user->authorizedInput($request->validated()));
Derive Form Request rules from the same source. If your rules array (['role' => 'sometimes|in:admin,user']) and your authorization rules live in two different files, they will drift. Where the package exposes the authorized attribute list, use it to intersect with $request->validate() output. If it doesn't expose one, wrap the package call in a small helper on a base model so you do have a single accessor.
Use it in API Resources for read-side filtering. The view/read half of the package is the underrated half. role and internal notes leaking into a UserResource payload is a far more common bug than a mass-assignment hole. Call the authorized-attribute check inside toArray() so a field appears in the JSON only when the requester is allowed to see it.
Context matters — create vs. update. A field like email may be creatable by an admin but not updatable by the user. Design your rule definitions with an operation/context argument from day one, because retrofitting a "context" into the rule array later touches every model.
Policy delegation for anything non-trivial. For simple boolean columns, a closure in the attribute list is fine. Once a rule needs the target user, tenant, or subscription, move the logic into a Policy and have the attribute rule call the gate. Keeps the model file readable and makes the rules unit-testable in isolation.
Roll it out model by model. Don't add the trait to all 40 models in one PR. Order: User → anything with tenant_id/account_id → anything with money → the long tail. Each model added is one place you can now stop writing defensive $request->only().
$fillable is not replaced. The package filters what a user is authorized to touch; it does not stop Model::create($request->all()) elsewhere in the app. Keep $fillable tight as a second line of defense. If the package ever silently lets an unauthorized key through, $fillable is what saves you from a CVE in your own app.password_reset_token column tomorrow is instantly world-writable and nothing warns you. Flip the default to deny, and expect the upgrade cost to be "list every attribute once per model." Pay it.hidden / visible are unrelated. $hidden is static and user-agnostic; this package is per-requester. Having both means a field can be hidden and still authorized, or visible and unauthorized depending on which layer you bypass (e.g. ->toArray() vs ->only()). Decide which layer owns serialization and be consistent.Gate::check() (and possibly DB queries) once per attribute per model. On an index page with 50 models × 8 attributes that's 400 authorization calls. Cache the permission result per request, and prefer resolving the ability once per request-scoped "current user context" rather than inside each attribute closure.$this->tenant->owner_id) will trigger lazy loads and break under Model::preventLazyLoading(). Eager-load in authorizedAttributes() or restrict rules to the model's own columns.update() returning success while silently dropping fields. This is the intended behavior, but it makes UX confusing: a user edits a field, sees a 200, and nothing changed. Validate first, then filter, then write — and surface a 403 on the form for genuinely unauthorized fields rather than silently discarding them. Silent filtering hides bugs during development; a strict/debug mode that throws on unauthorized input is worth turning on locally.dd() of the authorized-attribute resolution for that model and user, not stepping through the service provider. Wrap the package call in a tiny authorizedAttributesFor($user) accessor early — you'll use it constantly, and it gives you a seam to mock in tests.role" are cheap and everyone writes them. The test that matters is "non-admin posting role=admin results in role unchanged." Write that for every sensitive column, and assert on the database, not the response.How can I help you explore Laravel packages today?