Practical PHP Attributes Introduced for Laravel Applications
PHP 8.0 introduced native attributes — a structured metadata syntax that replaces docblock annotations. Laravel 13 is the first Laravel release to lean into them at framework scale, shipping attribute support across 15+ framework locations. This article walks through every major attribute available in Laravel 13, explains when to use them, and calls out the traps developers fall into when adopting them.
Prerequisites
- PHP 8.3 or higher (hard minimum for Laravel 13)
- Laravel 13.x (
composer require laravel/framework:^13.0) - Familiarity with Eloquent models, controllers, middleware, and jobs
For the full picture of what shipped in Laravel 13 beyond attributes, see Laravel 13: Features, Upgrade Guide, and Breaking Changes.
What PHP Attributes Actually Are
A PHP attribute is a structured piece of metadata you attach directly to a class, method, property, or parameter using #[...] syntax. Before PHP 8.0, developers used docblock annotations like @ORM\Column(type="string"). Those worked only if a library parsed the docblock at runtime. Native attributes are parsed by PHP itself — no string parsing required.
// Before (docblock annotations — library must parse these at runtime)
/**
* @Table(name="posts")
* @Fillable({"title", "body"})
*/
class Post extends Model {}
// After (native PHP attributes — parsed by the PHP engine)
#[Table('posts')]
#[Fillable('title', 'body')]
class Post extends Model {}
The difference matters: native attributes are faster to resolve, work with static analysis tools like PHPStan out of the box, and are visible in IDE autocompletion without special plugins.
Eloquent Model Attributes
The most widely used attributes in Laravel 13 live on Eloquent models. They replace a cluster of static properties that every model used to carry.
use Illuminate\Database\Eloquent\Attributes\Table;
use Illuminate\Database\Eloquent\Attributes\Fillable;
use Illuminate\Database\Eloquent\Attributes\Hidden;
use Illuminate\Database\Eloquent\Attributes\Cast;
#[Table('posts', primaryKey: 'post_id', incrementing: true, timestamps: true)]
#[Fillable('title', 'body', 'user_id', 'published_at')]
#[Hidden('deleted_at', 'internal_score')]
#[Cast('published_at', 'datetime')]
#[Cast('metadata', 'array')]
class Post extends Model {}
What each attribute replaces:
| Attribute | Replaces |
|---|---|
#[Table('posts', primaryKey: 'id')] |
$table, $primaryKey, $incrementing, $timestamps
|
#[Fillable('title', 'body')] |
$fillable array |
#[Hidden('secret')] |
$hidden array |
#[Cast('column', 'type')] |
$casts array |
Important: Both Syntaxes Work
Attributes do not replace the property-based syntax. If you declare both $fillable and #[Fillable(...)] on the same model, the property wins. Pick one approach per model and stay consistent.
// This will NOT behave as expected — $fillable overrides #[Fillable]
#[Fillable('title')]
class Post extends Model {
protected $fillable = ['title', 'body']; // body is accessible, but attribute is ignored
}
Controller and Routing Attributes
Controllers gain #[Middleware] and #[Authorize] attributes that move protection declarations out of the constructor and into the method signature.
use Illuminate\Routing\Attributes\Controllers\Middleware;
use Illuminate\Routing\Attributes\Controllers\Authorize;
#[Middleware('auth')] // applies to every method in this controller
#[Middleware('verified')] // stacked — both run
class PostController extends Controller
{
public function index() {
// auth + verified middleware runs here
}
#[Middleware('subscribed')] // method-level addition
#[Authorize('create', [Post::class, 'category'])] // policy check
public function store(Request $request) {
// auth + verified + subscribed + policy gate runs here
}
#[Middleware('admin')] // only admin needed here
public function destroy(Post $post) {
// auth + verified + admin middleware runs here
}
}
The #[Authorize] attribute maps directly to $this->authorize() — it accepts the ability name and an optional model or array of models. If the gate check fails, Laravel throws an AuthorizationException before the method body runs.
When Controller Attributes Make Sense
Use them when different methods in the same controller have meaningfully different authorization rules. If every method needs the same middleware, the constructor $this->middleware() approach remains cleaner:
// Still valid — and arguably more visible for uniform middleware
class ApiController extends Controller {
public function __construct() {
$this->middleware(['auth:sanctum', 'throttle:api']);
}
}
Job and Queue Attributes
Jobs in Laravel have always accepted configuration through public properties ($queue, $connection, $tries, $timeout). Attributes provide the same control at the class definition level.
use Illuminate\Queue\Attributes\Queue as QueueName;
use Illuminate\Queue\Attributes\Connection;
use Illuminate\Queue\Attributes\Tries;
use Illuminate\Queue\Attributes\Timeout;
use Illuminate\Queue\Attributes\BackoffStrategy;
#[QueueName('video-processing')]
#[Connection('redis')]
#[Tries(3)]
#[Timeout(120)]
#[BackoffStrategy([10, 30, 60])]
class TranscodeVideo implements ShouldQueue
{
public function handle(): void
{
// video processing logic
}
}
Note that Queue::route() (also new in Laravel 13) overrides job-level queue/connection configuration from a service provider. If you use both, Queue::route() takes precedence. Use attributes when the routing belongs to the job itself; use Queue::route() when you need centralized routing control across many jobs.
Artisan Command Attributes
Artisan commands now accept attributes for their signature and description instead of class properties:
use Illuminate\Console\Attributes\Command as CommandAttribute;
#[CommandAttribute('posts:publish {--dry-run}', description: 'Publish all scheduled posts')]
class PublishScheduledPosts extends Command
{
public function handle(): void
{
$dryRun = $this->option('dry-run');
// logic here
}
}
This is syntactically clean, but the practical benefit over protected $signature and protected $description is modest. The command still needs to extend Illuminate\Console\Command and the handle() method works identically.
Listener and Event Attributes
Event listeners can declare their event binding using an attribute rather than type-hinting the event class:
use Illuminate\Events\Attributes\ListensTo;
#[ListensTo(PostPublished::class)]
class SendPublishedNotification
{
public function handle(PostPublished $event): void
{
// send notification
}
}
For listeners auto-discovered by Laravel's event system, this attribute makes the binding explicit and statically analysable without requiring the listener to be registered in EventServiceProvider.
Common Mistakes and Limitations
1. Mass-converting existing models without a plan
The most common mistake teams make is running a find-and-replace on $fillable and $hidden across every model. This creates a large diff with zero runtime benefit and introduces inconsistency if any model gets missed. Adopt attributes incrementally — new models first, existing models during refactors.
2. Assuming attributes provide runtime validation
PHP attributes are metadata. #[Fillable('title')] does not throw if you try to mass-assign body — Laravel reads the attribute at model boot and populates its internal $fillable array. The validation behaviour is identical to the property approach.
3. Stacking conflicting middleware attributes
Attributes stack. If a parent class has #[Middleware('auth')] and a child class adds #[Middleware('auth')] again, both run — resulting in the middleware executing twice. Always check the inheritance chain before stacking.
4. Forgetting import statements
Attributes require a use statement for each attribute class. Unlike properties, which are just array values, each attribute has a fully qualified class name. A missing import causes a fatal error at class-load time, not at the point of use.
// Missing this import will throw at model boot, not at query time
use Illuminate\Database\Eloquent\Attributes\Fillable;
5. Mixing property and attribute syntax in the same model
As noted above, the property-based values take precedence. If you have both on the same model, the attribute is silently ignored. Enable PHPStan or run a project-wide grep after migration to confirm there are no mixed cases:
grep -rn 'protected \$fillable\|#\[Fillable' app/Models/
Testing Models and Controllers Using Attributes
Attributes do not change how you test models or controllers — the test API is identical. You can verify attribute resolution indirectly:
// Test that fillable is correctly resolved from #[Fillable] attribute
public function test_post_fillable_fields(): void
{
$post = new Post();
$this->assertEquals(['title', 'body', 'user_id', 'published_at'], $post->getFillable());
}
// Test that middleware protects the route (controller attribute in effect)
public function test_store_requires_auth(): void
{
$response = $this->postJson('/api/posts', ['title' => 'Test']);
$response->assertStatus(401);
}
// Test that unauthorized user cannot create a post (Authorize attribute)
public function test_store_requires_create_permission(): void
{
$user = User::factory()->create(); // user without create permission
$response = $this->actingAs($user)->postJson('/api/posts', ['title' => 'Test']);
$response->assertStatus(403);
}
When to Use Attributes vs. Properties
| Situation | Recommendation |
|---|---|
| New model in a greenfield project | Use attributes — consistent from the start |
| Existing model mid-project | Keep properties until a natural refactor point |
| Controller with varied per-method authorization | Use #[Authorize] and #[Middleware] — it's more readable |
| Controller with uniform middleware for all methods | Keep constructor $this->middleware() — less noise |
| Job configuration owned by the job itself | Use job attributes |
| Job routing owned by ops / infrastructure | Use Queue::route() in a service provider |
| Team unfamiliar with PHP attribute syntax | Defer adoption — the old syntax works indefinitely |
Static Analysis and Tooling Support
Native PHP attributes integrate cleanly with the PHP ecosystem:
-
PHPStan / Psalm: Both understand
#[...]attribute syntax natively. No special stubs needed for the attributes themselves, though you may need Laravel-specific stubs for the IDE to understand what each attribute does at runtime. -
PhpStorm / VS Code with Intelephense: Full autocompletion on attribute class names and constructor parameters once the
laravel/frameworksource is indexed. - Laravel Telescope / Debugbar: Attribute-configured middleware and authorization run identically to property-based configuration — the debug bar output looks the same.
Summary
PHP Attributes in Laravel 13 are an additive, optional improvement to developer ergonomics. They collocate configuration with the class it belongs to, make metadata visible to static analysis tools, and reduce the number of class properties a model or controller carries. They do not change runtime behaviour — every attribute maps to an existing framework mechanism.
The practical rule: adopt them in new code, migrate existing code only during planned refactors, and never mix both syntaxes in the same class.
If you need Laravel development in Mumbai, Mumbai Web Designer builds production-grade Laravel applications.
Top comments (0)