Skip to content

Laravel validation, authorization, policies, and tenant boundaries

Status: Complete. Last reviewed 2026-08-27.

Validation, authorization, domain invariants, and tenant isolation reject different kinds of invalid operations:

  • Validation asks whether input has an acceptable shape and satisfies request-level rules.
  • Authorization asks whether an actor may perform an ability on a resource or class of resources.
  • A domain invariant states what must remain true regardless of whether the caller is HTTP, CLI, a queue worker, or another service.
  • Tenant isolation ensures one tenant’s data and effects cannot be reached from another tenant’s context.

Passing one layer does not imply the others. An existing project_id may be well formed yet belong to another tenant. A user may be allowed to update a project but propose an impossible state transition. A unique validation rule may pass twice under concurrency while only a database constraint can prevent both writes.

The safe design is defense in depth with one clear owner for each decision. Repeating the same policy ad hoc in controllers, jobs, and Blade templates creates drift; relying on one HTTP check leaves non-HTTP paths exposed.

When a controller action type-hints a Form Request, Laravel resolves it through the container before invoking the controller. The current framework sequence is:

  1. prepareForValidation() may normalize or merge input.
  2. authorize() is called when present; a denial stops processing with an authorization exception.
  3. Laravel creates the validator from rules() and related configuration.
  4. Validator after-hooks run as part of validation; failed validation throws a validation exception.
  5. passedValidation() runs only after successful validation.
  6. The controller receives the resolved Form Request.

This ordering has consequences. Preparation happens before authorization, so it should be bounded normalization, not an externally visible side effect. Authorization may use an already bound route model, but should not duplicate a global lookup when the route already supplied the resource. Validation rules and authorize() may receive container-injected dependencies, yet expensive network calls in either path increase latency for rejected requests.

For a traditional browser request, validation failure normally redirects back and flashes errors and input to the session. When the request expects JSON, Laravel returns a structured 422 response. Form Request authorization failure normally becomes 403. Those are different client contracts: 422 means the operation’s submitted representation is unacceptable; 403 means the identified actor is not permitted.

after() is useful for cross-field request consistency that requires the assembled validator. It is not a place to persist state. passedValidation() can normalize the request after success, but constructing an explicit command or DTO from validated values usually makes the application boundary clearer than mutating the request into a domain object.

validated() returns an array of data covered by validation; safe() returns a ValidatedInput wrapper with only(), except(), and all(). Neither means HTML-safe, authorized, tenant-owned, or valid for mass assignment. “Safe” here describes membership in the validated set, not universal trust.

Nested arrays deserve special attention. If an array rule does not list allowed keys, the validated result can include keys inside that array that have no nested rule. Declare allowed keys such as array:name,username and select the fields needed by the operation. Do not accept tenant_id, owner_id, role, price, or status from the client merely because the field can be validated; derive authority-bearing values from trusted context or application policy.

Database-backed rules also have narrower meaning than their names suggest. exists proves a row matches a query, not that the actor may reference it. unique is a friendly pre-check, not a concurrency guarantee; enforce actual uniqueness in the database and handle the constraint failure. When using Rule::unique()->ignore(...), pass a system-derived model or key, never user-controlled input.

Validation should produce a deliberate input boundary. Mass-assignment configuration remains defense in depth, but $model->update($request->validated()) is risky when rules grow over time and persistence fields have different ownership. Mapping validated input to named operation fields makes privilege-sensitive assignments visible.

Gates and policies use the same authorization system. A gate is convenient for an ability not naturally organized around one model, such as viewing an operations dashboard. A policy groups abilities around a resource type, such as view, create, update, and delete for Project. Policies are resolved through the container, so they can depend on focused collaborators.

Laravel can discover policies by naming convention, or an application can register mappings explicitly. Discovery is convenience, not evidence that a check occurs. Enforcement may happen through Gate::authorize(), user can()/cannot(), the can middleware, a Form Request’s authorize(), or another explicit call. Blade @can controls presentation only; the server-side operation must still authorize.

For an existing resource, pass the model to the ability. For an action such as create with no instance yet, pass the model class. Additional context can be supplied when the ability truly depends on an operation value, but a long argument list suggests the policy may need a named context object or a clearer domain service.

A policy method may return bool or an authorization Response. Responses can carry a message and a chosen HTTP status. Gate::authorize() throws on denial, and Laravel renders a 403 by default. Response::denyAsNotFound() or another denial status can deliberately conceal whether a resource exists. Keep that choice consistent across binding and policy failures so an endpoint does not reveal existence through status, timing, or error text.

before() is a policy-wide override for abilities the policy implements: return true to allow, false to deny, and null to continue to the named method. Broad “administrator can do everything” overrides are dangerous in multi-tenant systems; platform access should still require an explicit, auditable cross-tenant mode. Gate after() hooks do not replace a non-null decision, and inline allowIf()/denyIf() checks do not run registered before/after hooks. Hidden hook semantics are a reason to keep exceptional access small and tested.

Guests are denied before policy methods by default. A policy that intentionally considers guests must accept a nullable user. Authentication still only establishes who the actor is; it is not authorization.

Tenant isolation as an end-to-end invariant

Section titled “Tenant isolation as an end-to-end invariant”

Tenant selection is not tenant proof. A host name, path segment, header, token claim, or request field can locate a tenant candidate, but the application must establish that the authenticated actor is associated with that tenant. Derive a trusted tenant context early, make absence fail closed, and keep it request/job scoped under long-running workers.

Then carry the tenant dimension through every surface:

  • Reads: query through tenant relationships or explicit tenant predicates; test that direct identifiers cannot escape the scope.
  • Writes: assign tenant_id from trusted context, not validated client input; use database constraints to preserve tenant/resource relationships where practical.
  • Policies: evaluate actor, tenant context, resource tenant, and ability rather than checking only a global role.
  • Queues: include a server-derived tenant identifier, establish context before resolving tenant-owned models, and decide whether delayed work must reauthorize the actor.
  • Cache and locks: include the tenant and every authorization-relevant dimension in keys.
  • Storage, search, exports, and broadcasts: partition names/queries/channels and authorize retrieval, not just generation.
  • Logs and metrics: attach tenant identifiers useful for investigation without logging sensitive tenant data.

Scoped route binding is useful defense in depth because it resolves a child through its parent relationship. It protects only that route-resolution path. A policy must still decide whether the actor may use the related object, and application queries outside the route must still be scoped. See the routing explanation and focused route example.

An Eloquent global scope can reduce accidental unscoped reads in a shared-table design, but it is not a complete security boundary. Code can remove scopes; raw queries, joins, jobs, imports, and administrative tools may bypass them. Make missing tenant context an error instead of silently returning all rows. Higher-assurance systems may add composite foreign keys, database row-level security, separate schemas, or separate databases, accepting greater operational complexity for stronger isolation.

Failure timeline: authorized request, unsafe export job

Section titled “Failure timeline: authorized request, unsafe export job”
  1. A user in tenant A passes a policy and requests an export.
  2. The controller dispatches a job containing only an attacker-influenced project ID.
  3. The worker later calls Project::findOrFail($id) without first establishing tenant A’s trusted context.
  4. The job finds tenant B’s project, writes the export to a shared path, and caches its location under export:{project}.
  5. A download endpoint checks only possession of the export ID, exposing tenant B’s data.
  6. HTTP feature tests stay green because they assert the initial policy but fake the queue.

The repair crosses boundaries: construct the payload from the authorized model and server-derived tenant, restore tenant context before model resolution, query with both identifiers, choose whether actor permission must be rechecked, tenant-scope storage and cache keys, and run a real job integration test with two tenants. A policy call at dispatch time cannot make later unscoped code safe.

Use 401 when authentication is required but absent or invalid, 403 when the actor is known and denied, 404 when the resource is absent or existence is intentionally concealed, and 422 for semantically invalid submitted input. The security property is not the status code alone: responses, timing, logs, and side effects must avoid leaking protected data.

A useful authorization test matrix varies actor, active tenant, resource tenant, and ability. Cover an owner, another member, a tenant administrator, an outsider, a guest, a related-but-forbidden resource, and an unrelated resource. Assert response shape and absence of writes, jobs, cache entries, broadcasts, and export files. Unit-test complex policy decisions, then feature/integration-test every real enforcement path. Queue::fake() can verify dispatch but cannot prove worker-side tenant isolation.

Operational evidence should distinguish validation failures from authorization denials and missing resources, with rate-controlled security logging for suspicious cross-tenant attempts. Never put detailed policy reasons or sensitive attributes into a client response simply because an authorization Response supports a message.

Current (Laravel 13): Form Requests retain the prepare → authorize → validate → passed-validation lifecycle; policies may be discovered conventionally or mapped explicitly; authorization responses can customize denial status; and fresh applications commonly register authorization customization from AppServiceProvider.

Common (Laravel 11–12): these core mechanisms and response semantics are substantially the same. Applications vary more in where they organize tenant context and query scoping than in framework capability.

Legacy: older applications commonly register gates and policies in AuthServiceProvider, validate inline in controllers, or scatter role checks through middleware and views. Migration should first inventory every enforcement path and add regression tests; moving code into a policy without changing all callers can create a false sense of centralization.