laravel-contextual-attributes-a-practical-guide
Guides & Tutorials

A Practical Guide to Laravel's Contextual Attributes

Last Updated on July 1, 2025 6 min read

A Practical Guide to Laravel's Contextual Attributes

Laravel is full of features that, once you discover them, leave you both impressed and slightly embarrassed you never knew they existed. For me, one of those is ContextualAttribute. It's a powerful tool that helps you write cleaner, more expressive, and highly maintainable code by tidying up how you resolve and inject dependencies.

In this article, we'll talk a bit about what contextual attributes are, why they're so useful, and walk through a practical, real-world example of how to use them to simplify a common problem in API development.

What's a Contextual Attribute?

At its core, a contextual attribute is a special PHP 8 attribute that lets you take control of how the service container resolves a dependency for a specific variable.

Think about a typical controller method:

public function update(Request $request, string $id)
{
// ...
}

When you type-hint Request, Laravel's service container knows to inject the current HTTP request instance. But what if you need to inject something more specific, something that requires complex logic to resolve? For example, injecting a Workspace model that's determined by an API key sent in request headers, not URL parameters. That's where contextual attributes shine.

They allow you to attach an attribute to a parameter, like #[CurrentWorkspace], and tell Laravel: "Hey, when you see this attribute on a $workspace parameter (or any parameter at all), use my custom logic to resolve it."

Contextual attributes were introduced in Laravel 11 and Laravel already ships with several built-in ones like #[Storage], #[Auth], #[Cache], #[Config], #[DB], #[Log], and #[CurrentUser]. These all hook into the container's resolution process to inject specific values based on the context of the request.

When Should You Reach for Contextual Attributes?

Contextual attributes are not for everyday dependency injection. I mean, in some cases, a simple type-hint is usually enough (eg Route model binding). They excel in scenarios where resolving the dependency isn't straightforward and can't be handled by Laravel's standard mechanisms.

Here are a few use cases where they are a perfect fit:

  1. API Key Authentication: When your API determines the workspace/tenant from an API key rather than URL parameters - perfect for services like Stripe, Paystack, or Flutterwave where the resource is tied to the authentication token.
  2. Header-Based Context: Resolving resources from custom headers, user agents, or other request metadata that doesn't belong in the URL.
  3. Complex Authorization Patterns: When you need to resolve a resource that requires multiple authorization checks or business logic that goes beyond simple route model binding.
  4. Cross-cutting Dependencies: Injecting objects that are needed across multiple controllers but determined by complex application state rather than simple parameters.

And so much more.

A Practical Example: Workspace Resolution with Laravel Passport

Let's build a real-world example. Let's say we're developing a SaaS analytics platform similar to Google Analytics or Mixpanel. We can use Laravel Passport for API authentication, where each API token is scoped to a specific workspace. When clients make API calls like POST /api/events or GET /api/analytics/pageviews, they send their Passport token in the Authorization header.

Laravel Passport handles the authentication and gives us $request->user(), but we still need to:

  1. Determine which workspace the current token is scoped to
  2. Verify the workspace is active and the user has access
  3. Inject the resolved workspace into our controllers

The key insight here is that while Passport handles authentication, the workspace resolution still requires complex business logic that would otherwise be repeated in every controller method.

The Old Way: Manual Workspace Resolution in Every Controller

Even with Passport handling authentication, the traditional approach still involves repetitive workspace resolution:

app/Http/Controllers/AnalyticsController.php

class AnalyticsController extends Controller
{
public function pageviews(Request $request)
{
// Passport gives us the authenticated user
$user = $request->user();
 
// But we still need to figure out which workspace this token is for
$token = $user->token();
 
// Extract workspace from token scopes (e.g., 'workspace:123')
$workspaceScope = collect($token->scopes)->first(function ($scope) {
return str_starts_with($scope, 'workspace:');
});
 
if (!$workspaceScope) {
return response()->json([
'error' => 'Access denied',
'message' => 'The provided API token does not have access to any workspace. Please ensure your token includes the required workspace scope.',
'code' => 'WORKSPACE_SCOPE_MISSING'
], 403);
}
 
$workspaceId = str_replace('workspace:', '', $workspaceScope);
 
// Find and validate the workspace
$workspace = Workspace::where('id', $workspaceId)
->where('is_active', true)
->whereHas('users', function ($query) use ($user) {
$query->where('id', $user->id);
})
->first();
 
if (!$workspace) {
return response()->json([
'error' => 'Access denied',
'message' => 'The workspace could not be found or you do not have access to it. Please verify the workspace ID and your permissions.',
'code' => 'WORKSPACE_ACCESS_DENIED'
], 403);
}
 
// Finally, the actual business logic
$pageviews = $workspace->analytics()
->where('event_type', 'pageview')
->whereBetween('created_at', [$request->start_date, $request->end_date])
->count();
 
return response()->json(['pageviews' => $pageviews]);
}
 
public function storeEvent(Request $request)
{
// We have to duplicate ALL of this workspace resolution logic again!
$user = $request->user();
$token = $user->token();
 
$workspaceScope = collect($token->scopes)->first(function ($scope) {
return str_starts_with($scope, 'workspace:');
});
 
if (!$workspaceScope) {
return response()->json([
'error' => 'Access denied',
'message' => 'The provided API token does not have access to any workspace. Please ensure your token includes the required workspace scope.',
'code' => 'WORKSPACE_SCOPE_MISSING'
], 403);
}
 
$workspaceId = str_replace('workspace:', '', $workspaceScope);
 
$workspace = Workspace::where('id', $workspaceId)
->where('is_active', true)
->whereHas('users', function ($query) use ($user) {
$query->where('id', $user->id);
})
->first();
 
if (!$workspace) {
return response()->json([
'error' => 'Access denied',
'message' => 'The workspace could not be found or you do not have access to it. Please verify the workspace ID and your permissions.',
'code' => 'WORKSPACE_ACCESS_DENIED'
], 403);
}
 
// Business logic...
$workspace->analytics()->create($request->validated());
 
return response()->json(['success' => true]);
}
}

This is painful! Even with Passport handling authentication, we still have to repeat complex workspace resolution logic in every single API method.

A Better Way: Middleware + Contextual Binding

Before rushing into contextual attributes, let's take a look at an alternative that's been available as far back as Laravel 6: combining middleware with contextual binding. This approach can solve our repetition problem, but as you'll see, it has some drawbacks.

Step 1: The Workspace Resolution Middleware

First, we create a middleware that resolves the workspace from the authenticated user's token scopes:

app/Http/Middleware/ResolveWorkspaceFromToken.php

<?php
 
namespace App\Http\Middleware;
 
use App\Models\Workspace;
use Closure;
use Illuminate\Http\Request;
 
class ResolveWorkspaceFromToken
{
public function handle(Request $request, Closure $next)
{
// Passport has already authenticated the user
$user = $request->user();
 
if (!$user) {
return response()->json(['error' => 'Unauthenticated'], 401);
}
 
// Get the current Passport token
$token = $user->token();
 
$workspaceScope = collect($token->scopes)->first(function ($scope) {
return str_starts_with($scope, 'workspace:');
});
 
// Extract workspace scope from token (e.g., 'workspace:123')
if (!$workspaceScope) {
return response()->json([
'error' => 'Access denied',
'message' => 'The provided API token does not have access to any workspace. Please ensure your token includes the required workspace scope.',
'code' => 'WORKSPACE_SCOPE_MISSING'
], 403);
}
 
// Extract workspace ID from scope
$workspaceId = str_replace('workspace:', '', $workspaceScope);
 
// Find and validate the workspace
$workspace = Workspace::where('id', $workspaceId)
->where('is_active', true)
->where('subscription_status', 'active')
->whereHas('users', function ($query) use ($user) {
$query->where('id', $user->id)->where('role', '!=', 'banned');
})
->first();
 
if (!$workspace) {
return response()->json([
'error' => 'Access denied',
'message' => 'The workspace could not be found or you do not have access to it. Please verify the workspace ID and your permissions.',
'code' => 'WORKSPACE_ACCESS_DENIED'
], 403);
}
 
// Check if workspace has exceeded API limits
if ($workspace->hasExceededApiLimits()) {
return response()->json([
'error' => 'Rate limit exceeded',
'message' => 'This workspace has exceeded its API rate limits. Please upgrade your plan or try again later.',
'code' => 'RATE_LIMIT_EXCEEDED'
], 429);
}
 
// Bind the resolved workspace to the container
app()->instance('workspace.current', $workspace);
 
return $next($request);
}
}

Step 2: Register the Middleware (Pre-Laravel 11)

For Laravel versions before 11, we register the middleware in our app/Http/Kernel.php:

app/Http/Kernel.php

protected $routeMiddleware = [
// ... other middleware
'workspace.resolve' => \App\Http\Middleware\ResolveWorkspaceFromToken::class,
];

Step 3: Set Up Contextual Binding

In your AppServiceProvider, we set up contextual binding. We're basically telling Laravel: "Whenever these specific controllers need a Workspace, give them this resolved instance." Here's how it works:

app/Providers/AppServiceProvider.php

use App\Http\Controllers\AnalyticsController;
use App\Http\Controllers\EventController;
use App\Http\Controllers\ReportsController;
use App\Models\Workspace;
 
public function register()
{
$this->app->when([AnalyticsController::class, EventController::class, ReportsController::class)])
->needs(Workspace::class)
->give(function () {
return app('workspace.current');
});
}

Step 4: Update Your API Routes

routes/api.php

Route::middleware(['auth:api', 'workspace.resolve'])->prefix('api')->group(function () {
Route::post('/events', [AnalyticsController::class, 'storeEvent']);
Route::get('/analytics/pageviews', [AnalyticsController::class, 'pageviews']);
// ... all your workspace-scoped API endpoints
});

Step 5: Clean Controllers

Now our controller can be clean:

app/Http/Controllers/AnalyticsController.php

class AnalyticsController extends Controller
{
public function pageviews(Request $request, Workspace $workspace)
{
// Clean! But where did $workspace come from? 🤔
$pageviews = $workspace->analytics()
->where('event_type', 'pageview')
->whereBetween('created_at', [$request->start_date, $request->end_date])
->count();
 
return response()->json(['pageviews' => $pageviews]);
}
}

While this approach works and eliminates repetition, it has two major downsides:

  1. It's "Magic" and Confusing: When someone new joins your project and sees Workspace $workspace in the controller, they have no idea where it comes from. They might assume it's from route model binding, or get confused about how Laravel resolved it. You'd need to dig into service providers to understand the resolution logic.

  2. Manual Labor for Every Controller: Every time you create a new controller that needs the workspace, you have to remember to add it to the when()->needs()->give() binding in your service provider. This is tedious and error-prone - you might forget to add it and wonder why your controller isn't receiving the workspace.

Alternative pattern
Resolve the workspace in middleware and attach it to the request instead:

// In middleware
$request->merge(['current_workspace' => $workspace]);
 
// In controller
$workspace = $request->current_workspace;

While this works, you lose type-safety and the dependency becomes less explicit.

The Elegant Way: Middleware + Contextual Attribute (Laravel 11+)

Now let's see how contextual attributes solve both problems from the contextual binding and request merging approach. We'll use the same middleware, but instead of registering contextual bindings for every controller in the AppServiceProvider, we'll create a contextual attribute.

Step 1: Register the Middleware (Laravel 11+)

If using Laravel 11+, we register this middleware in our bootstrap/app.php file:

bootstrap/app.php

<?php
 
use App\Http\Middleware\ResolveWorkspaceFromToken;
use Illuminate\Foundation\Application;
use Illuminate\Foundation\Configuration\Exceptions;
use Illuminate\Foundation\Configuration\Middleware;
 
return Application::configure(basePath: dirname(__DIR__))
->withRouting(
web: __DIR__.'/../routes/web.php',
api: __DIR__.'/../routes/api.php',
commands: __DIR__.'/../routes/console.php',
health: '/up',
)
->withMiddleware(function (Middleware $middleware) {
// Register our workspace resolution middleware
$middleware->alias([
'workspace.resolve' => ResolveWorkspaceFromToken::class,
]);
})
->withExceptions(function (Exceptions $exceptions) {
//
})->create();

Step 2: The Custom Contextual Attribute

Next, we create our contextual attribute that retrieves the workspace from the container:

app/Http/Attributes/CurrentWorkspace.php

<?php
 
namespace App\Http\Attributes;
 
use App\Models\Workspace;
use Attribute;
use Illuminate\Contracts\Container\Container;
use Illuminate\Contracts\Container\ContextualAttribute;
 
#[Attribute(Attribute::TARGET_PARAMETER)]
class CurrentWorkspace implements ContextualAttribute
{
/**
* Resolve the current workspace from the service container.
*
* This method is called by Laravel's service container when it encounters
* this attribute on a parameter during dependency injection.
*/
public static function resolve(self $attribute, Container $container): Workspace
{
// The middleware already did all the heavy lifting:
// - Extracted workspace scope from Passport token
// - Validated workspace access and permissions
// - Checked subscription and API limits
// - Bound the workspace to the container
//
// We just retrieve the resolved workspace instance
return $container->make('workspace.current');
}
}

Step 3: Update Your API Routes

Update your API routes to use both Passport authentication and workspace resolution:

routes/api.php

Route::middleware(['auth:api', 'workspace.resolve'])->prefix('api')->group(function () {
Route::post('/events', [AnalyticsController::class, 'storeEvent']);
Route::get('/analytics/pageviews', [AnalyticsController::class, 'pageviews']);
Route::get('/analytics/sessions', [AnalyticsController::class, 'sessions']);
Route::get('/analytics/users', [AnalyticsController::class, 'users']);
// ... all your workspace-scoped API endpoints
});

Step 4: The Beautiful Controller

Now for the payoff! Our controller becomes incredibly clean and focused:

app/Http/Controllers/AnalyticsController.php

<?php
 
namespace App\Http\Controllers;
 
use App\Http\Attributes\CurrentWorkspace;
use App\Http\Requests\StoreEventRequest;
use App\Models\Workspace;
use Illuminate\Http\Request;
 
class AnalyticsController extends Controller
{
/**
* Get pageview analytics for the current workspace.
*/
public function pageviews(Request $request, #[CurrentWorkspace] Workspace $workspace)
{
$pageviews = $workspace->analytics()
->where('event_type', 'pageview')
->whereBetween('created_at', [$request->start_date, $request->end_date])
->selectRaw('DATE(created_at) as date, COUNT(*) as count')
->groupBy('date')
->orderBy('date')
->get();
 
return response()->json(['data' => $pageviews]);
}
 
/**
* Store a new analytics event.
*/
public function storeEvent(StoreEventRequest $request, #[CurrentWorkspace] Workspace $workspace)
{
$event = $workspace->analytics()->create([
'event_type' => $request->event_type,
'properties' => $request->properties,
'user_id' => $request->user_id,
'session_id' => $request->session_id,
'ip_address' => $request->ip(),
'user_agent' => $request->userAgent(),
]);
 
return response()->json(['success' => true, 'event_id' => $event->id]);
}
 
/**
* Get user analytics.
*/
public function users(Request $request, #[CurrentWorkspace] Workspace $workspace)
{
$users = $workspace->analytics()
->distinct('user_id')
->whereBetween('created_at', [$request->start_date, $request->end_date])
->count('user_id');
 
return response()->json(['unique_users' => $users]);
}
 
/**
* Notice how clean this is - both user and workspace are injected!
*/
public function profile(Request $request, #[CurrentWorkspace] Workspace $workspace)
{
$user = $request->user(); // From Passport
 
return response()->json([
'user' => $user->only(['name', 'email']),
'workspace' => $workspace->only(['name', 'plan']),
'permissions' => $user->permissionsForWorkspace($workspace),
]);
}
}

Look at how clean this is! Every method receives both the authenticated user (from Passport) and the resolved workspace (from our contextual attribute) without any boilerplate.

  1. No Service Provider Configuration: We completely eliminated the need to register contextual bindings in AppServiceProvider for every controller. New controllers automatically work with #[CurrentWorkspace] without any additional setup.

  2. Self-Documenting Code: When developers see #[CurrentWorkspace] Workspace $workspace, they immediately understand that this is a custom-resolved dependency. The attribute name clearly indicates its purpose.

  3. Zero Maintenance Overhead: Add as many controllers as you want - they'll all work with the same attribute without touching any service providers.

Advanced Usage: Multiple Workspace Contexts

You can even have different contextual attributes for different workspace access patterns:

class ApiController extends Controller
{
// For API tokens scoped to specific workspaces
public function analytics(#[CurrentWorkspace] Workspace $workspace)
{
// $workspace comes from token scope resolution
}
}
 
class WebController extends Controller
{
// For web users who can switch between workspaces
public function dashboard(#[SelectedWorkspace] Workspace $workspace)
{
// $workspace comes from session or URL parameter
}
}

Testing Your Contextual Attributes

Testing becomes straightforward since you can mock the container binding:

public function test_can_get_pageview_analytics()
{
$user = User::factory()->create();
$workspace = Workspace::factory()->create();
 
// Create a Passport token with workspace scope
$token = $user->createToken('Test Token', ['workspace:' . $workspace->id]);
 
// Mock the container binding that our middleware would normally create
$this->app->instance('workspace.current', $workspace);
 
$response = $this->withHeaders([
'Authorization' => 'Bearer ' . $token->accessToken,
])->get('/api/analytics/pageviews?start_date=2024-01-01&end_date=2024-01-31');
 
$response->assertOk();
}

Conclusion

Laravel's contextual attributes provide a powerful and elegant way to handle complex dependency resolution scenarios that can't be solved with simple route model binding. They're perfect for situations where:

  • Resources are determined by headers, API keys, or other non-URL data
  • You need complex authentication and authorization logic
  • You want type-safe dependency injection instead of pulling data from modified request objects
  • You have cross-cutting concerns that affect multiple controllers

This pattern transforms repetitive, error-prone authentication code into clean, declarative controller methods. It's a technique that scales beautifully as your API grows and adds more complex authentication requirements.

The next time you find yourself copying authentication and resource resolution logic across multiple API controllers, consider if a contextual attribute might provide a more elegant solution.

1 Comment

avatar

Shadow Walker

1 day ago

Good read. One more tip in my Laravel tip kit. I also got to know that you can attach attributes to methods Never thought of doing that.

|
avatar

Kyrian Obikwelu

1 day ago

Yeah. PHP attributes can be attached to classes, methods, functions, parameters, properties, and constants. I was so excited when it came to PHP 8 cos I was a huge fan of it in C#

Would you like to say something? Please log in to join the discussion.

Login with GitHub