Una de las novedades más prácticas de Laravel 13 es que muchas configuraciones que antes eran propiedades de la clase ($fillable, $hidden, $table…) o métodos del constructor (como $this->middleware()) se pueden declarar ahora con atributos PHP, justo encima de la clase o del método.

Son opcionales: la sintaxis clásica sigue funcionando. Pero hacen el código más declarativo y fácil de leer. Yo los he empezado a usar en mis proyectos nuevos y los ejemplos de este artículo salen de código real.

Atributos en los modelos de Eloquent

#[Fillable] y #[Hidden]

Sustituyen a las propiedades $fillable y $hidden:

// Antes
class User extends Authenticatable
{
    protected $fillable = ['name', 'email', 'password'];

    protected $hidden = ['password', 'remember_token'];
}

// Con atributos (Laravel 13)
use Illuminate\Database\Eloquent\Attributes\Fillable;
use Illuminate\Database\Eloquent\Attributes\Hidden;

#[Fillable(['name', 'email', 'password'])]
#[Hidden(['password', 'remember_token'])]
class User extends Authenticatable
{
    //
}

En la misma línea están #[Guarded], #[Visible] y #[Appends].

#[Table]: tabla, clave primaria y timestamps

Agrupa en un solo sitio la configuración de la tabla:

use Illuminate\Database\Eloquent\Attributes\Table;

#[Table(name: 'legacy_posts', key: 'post_id', incrementing: true, timestamps: false)]
class Post extends Model
{
    //
}

Admite name, key, keyType, incrementing, timestamps y dateFormat. Si solo necesitas desactivar los timestamps, también existe #[WithoutTimestamps].

#[Scope]: scopes sin el prefijo scope

Antes, un scope local tenía que llamarse scopePublished(). Con el atributo #[Scope] el método se llama como lo vas a usar:

use Illuminate\Database\Eloquent\Attributes\Scope;
use Illuminate\Database\Eloquent\Builder;

class Post extends Model
{
    #[Scope]
    protected function published(Builder $query): void
    {
        $query->where('status', 'published')
            ->where('published_at', '<=', now());
    }
}

// Uso
Post::query()->published()->latest('published_at')->get();

Un consejo de algo que me pasó al actualizar una aplicación antigua: evita nombres genéricos como except u only en los scopes, porque Eloquent ya tiene métodos nativos con esos nombres y tienen prioridad.

#[RouteKey]: buscar por slug en las rutas

Indica la columna que usará el route model binding, sin sobrescribir getRouteKeyName():

use Illuminate\Database\Eloquent\Attributes\RouteKey;

#[RouteKey('slug')]
class Project extends Model
{
    //
}

// /proyectos/{project} buscará por slug en lugar de por id
Route::get('/proyectos/{project}', [ProjectController::class, 'show']);

Observers, scopes globales y factories

use Illuminate\Database\Eloquent\Attributes\ObservedBy;
use Illuminate\Database\Eloquent\Attributes\ScopedBy;
use Illuminate\Database\Eloquent\Attributes\UseFactory;

#[ObservedBy(OrderObserver::class)]
#[ScopedBy(ActiveScope::class)]
#[UseFactory(OrderFactory::class)]
class Order extends Model
{
    //
}

Hay más: #[Connection], #[Touches], #[CollectedBy], #[UsePolicy], #[UseResource]… Todos están en el namespace Illuminate\Database\Eloquent\Attributes.

Atributos en los controladores

#[Middleware]

En lugar de llamar a $this->middleware() en el constructor, puedes declararlo en la clase o en cada método:

use Illuminate\Routing\Attributes\Controllers\Middleware;

#[Middleware('auth')]
class InvoiceController
{
    #[Middleware('verified')]
    public function store(Request $request)
    {
        // ...
    }
}

También admite los parámetros only y except para aplicarlo solo a algunos métodos.

#[Authorize]

Comprueba una policy antes de ejecutar la acción, sin escribir $this->authorize() dentro del método:

use App\Models\Comment;
use App\Models\Post;
use Illuminate\Routing\Attributes\Controllers\Authorize;
use Illuminate\Routing\Attributes\Controllers\Middleware;

#[Middleware('auth')]
class CommentController
{
    #[Authorize('create', [Comment::class, 'post'])]
    public function store(Post $post)
    {
        // ...
    }
}

Atributos en los jobs de cola

Los reintentos, esperas y tiempos máximos de un job también se declaran con atributos:

use Illuminate\Queue\Attributes\Backoff;
use Illuminate\Queue\Attributes\Timeout;
use Illuminate\Queue\Attributes\Tries;

#[Tries(3)]
#[Backoff(10, 60, 300)]
#[Timeout(120)]
class SendInvoiceEmail implements ShouldQueue
{
    // ...
}

#[Backoff] acepta un único valor en segundos o varios valores para cada reintento. Además existe #[FailOnTimeout].

¿Merece la pena migrar a atributos?

  • En código nuevo: sí. La configuración queda junto a la clase o el método al que afecta y se lee de un vistazo.
  • En código existente: no hay prisa. La sintaxis clásica sigue funcionando y Laravel no la ha marcado como obsoleta. Puedes ir migrando a medida que tocas cada archivo.

Lo importante es ser coherente dentro de un mismo proyecto, para no mezclar las dos formas en clases parecidas.

¿Tienes una aplicación en una versión antigua de Laravel y quieres aprovechar estas mejoras? Te ayudo a actualizarla.

Fuentes