DEV Community

Cover image for Practical PHP Attributes Introduced for Laravel Applications
Sumeet Shroff
Sumeet Shroff

Posted on

Practical PHP Attributes Introduced for Laravel Applications

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 {}
Enter fullscreen mode Exit fullscreen mode

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 {}
Enter fullscreen mode Exit fullscreen mode

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
}
Enter fullscreen mode Exit fullscreen mode

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
    }
}
Enter fullscreen mode Exit fullscreen mode

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']);
    }
}
Enter fullscreen mode Exit fullscreen mode

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
    }
}
Enter fullscreen mode Exit fullscreen mode

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
    }
}
Enter fullscreen mode Exit fullscreen mode

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
    }
}
Enter fullscreen mode Exit fullscreen mode

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;
Enter fullscreen mode Exit fullscreen mode

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/
Enter fullscreen mode Exit fullscreen mode

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);
}
Enter fullscreen mode Exit fullscreen mode

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/framework source 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.

Read the full guide

Top comments (0)