Si creas un modelo con create() y justo después lees una columna que rellena la base de datos, Eloquent te devuelve null, aunque en la tabla el valor está guardado. Pasa con los valores por defecto de la migración, como un status que empieza en pending, y con las columnas generadas con storedAs() o virtualAs(), que además se quedan con el valor anterior después de un update().
Laravel 13.33, del 22 de septiembre de 2026, añade el atributo #[Refreshes] (PR #61523), que vuelve a leer esas columnas después de cada escritura. En esta entrada vamos a ver el SQL que lanza Eloquent para entender por qué pasa y por qué el modo estricto no lo detecta. Después veremos qué consulta añade #[Refreshes], en qué se diferencia de refresh() y un fallo de la 13.33 que conviene conocer antes de usarlo. Todo está probado, en el momento de crear esta entrada, con Laravel 13.36, PHP 8.5 y MySQL 8.4 en Laravel Sail.
Una tabla de pedidos
Vamos a crear un modelo Order con su migración:
php artisan make:model Order -m
Abre la migración que se acaba de crear y cambia el método up() por el siguiente:
public function up(): void
{
Schema::create('orders', function (Blueprint $table) {
$table->id();
$table->string('reference')->unique();
$table->string('status')->default('pending');
$table->unsignedInteger('quantity')->default(1);
$table->decimal('unit_price', 8, 2);
$table->decimal('total', 10, 2)->storedAs('quantity * unit_price');
$table->timestamps();
});
}
Si te fijas, hay 3 columnas que no rellena nuestro código. status y quantity tienen un valor por defecto, y total es una columna generada: MySQL la calcula al guardar la fila, multiplicando quantity por unit_price. Con storedAs() el resultado se guarda en la tabla y con virtualAs() se calcula cada vez que se lee, pero para Eloquent las 2 se comportan igual.
El modelo, en app/Models/Order.php, solo declara los campos que se pueden asignar:
<?php
namespace App\Models;
use Illuminate\Database\Eloquent\Attributes\Fillable;
use Illuminate\Database\Eloquent\Model;
#[Fillable(['reference', 'status', 'quantity', 'unit_price'])]
class Order extends Model
{
//
}
Lo que devuelve create()
Ejecuta php artisan migrate y abre Tinker con php artisan tinker. Vamos a imprimir el SQL de cada consulta con DB::listen() y a crear un pedido sin estado ni cantidad. En el comentario de cada línea tienes lo que devuelve:
use App\Models\Order;
DB::listen(function ($query) {
echo $query->toRawSql(), PHP_EOL;
});
$order = Order::create(['reference' => 'A-1001', 'unit_price' => 19.90]);
$order->status; // null
$order->total; // null
Order::find($order->id)->only('status', 'quantity', 'total');
// ['status' => 'pending', 'quantity' => 1, 'total' => '19.90']
Estas son las 2 consultas que imprime el listener:
insert into `orders` (`reference`, `unit_price`, `updated_at`, `created_at`) values ('A-1001', 19.9, '2026-10-14 01:49:53', '2026-10-14 01:49:53')
select * from `orders` where `orders`.`id` = 1 limit 1
El INSERT solo lleva las columnas que tiene el modelo: las 2 que le hemos pasado y las 2 fechas que añade Eloquent. MySQL rellena el resto, pero de esa fila Eloquent solo recupera el id que se acaba de generar. Si miras $order->getAttributes(), verás reference, unit_price, updated_at, created_at e id, y nada más. Como status no está entre ellos, Eloquent devuelve null al leerlo.
Lo malo es que no se nota a simple vista. Un controlador que devuelve el pedido recién creado envía un JSON sin status ni total, y una condición como $order->status === 'pending' da false con un pedido que está pendiente.
Después de update(), el valor anterior
Con las columnas generadas hay un segundo problema. Vamos a leer el pedido de la base de datos, que ahora sí trae todas las columnas, y a cambiar la cantidad:
$order = Order::find(1);
$order->total; // '19.90'
$order->update(['quantity' => 3]);
$order->total; // '19.90'
Order::find(1)->total; // '59.70'
El UPDATE cambia quantity, MySQL recalcula total y el modelo se queda con el importe de antes, porque Eloquent no vuelve a leer la fila después de guardarla. Todo el código que use ese objeto después del update() trabaja con un total que ya no es el de la base de datos.
El modo estricto no lo detecta
Laravel tiene una protección para este caso: Model::preventAccessingMissingAttributes(), que también se activa con Model::shouldBeStrict(), como vimos en Eloquent Strict Mode. Con ella, leer una columna que no se ha cargado lanza una excepción en lugar de devolver null. Pero con un modelo recién creado no la lanza:
use Illuminate\Database\Eloquent\Model;
Model::shouldBeStrict();
$order = Order::create(['reference' => 'A-1002', 'unit_price' => 5]);
$order->status; // null
Order::select('id')->find($order->id)->status;
// MissingAttributeException: The attribute [status] either does not exist
// or was not retrieved for model [App\Models\Order].
El motivo está en el código de Eloquent: la excepción solo se lanza si el modelo existe y no se acaba de crear (! $this->wasRecentlyCreated). Por eso, justo después de un create(), el modo estricto no te avisa.
Añadir #[Refreshes] al modelo
Hasta ahora, la solución era llamar a $order->refresh() después de guardar, en cada sitio donde hiciera falta. Desde Laravel 13.33 basta con decirle al modelo qué columnas rellena la base de datos. Abre app/Models/Order.php y añade el atributo:
<?php
namespace App\Models;
use Illuminate\Database\Eloquent\Attributes\Fillable;
use Illuminate\Database\Eloquent\Attributes\Refreshes;
use Illuminate\Database\Eloquent\Model;
#[Fillable(['reference', 'status', 'quantity', 'unit_price'])]
#[Refreshes(['status', 'quantity', 'total'])]
class Order extends Model
{
//
}
Si prefieres usar una propiedad, el equivalente es este:
protected array $refreshes = ['status', 'quantity', 'total'];
Vamos a repetir el create() y el update() de antes con un pedido nuevo, el A-1003, en otra sesión de Tinker y con el mismo listener. Esto es lo que imprime:
insert into `orders` (`reference`, `unit_price`, `updated_at`, `created_at`) values ('A-1003', 19.9, '2026-10-14 01:49:54', '2026-10-14 01:49:54')
select `status`, `quantity`, `total` from `orders` where `id` = 3 limit 1
update `orders` set `quantity` = 3, `orders`.`updated_at` = '2026-10-14 01:49:54' where `id` = 3
select `status`, `quantity`, `total` from `orders` where `id` = 3 limit 1
Y el modelo ya tiene los valores, sin llamar a refresh():
// Después del create()
$order->only('status', 'quantity', 'total'); // ['status' => 'pending', 'quantity' => 1, 'total' => '19.90']
// Después del update()
$order->total; // '59.70'
Si te fijas, después de cada escritura Eloquent lanza un SELECT por la clave primaria que pide solo las columnas de la lista, y copia los valores en el modelo. Lo hace antes de lanzar los eventos created y updated. Por eso un observer de created ya recibe el pedido con status y total, y después del update(), $order->wasChanged('total') devuelve true. También relee las columnas después de un increment(). Y la consulta va a la conexión de escritura, así que, si usas réplicas de lectura, no lee un dato atrasado.
#[Refreshes] frente a refresh()
Con #[Refreshes], create() pasa de 1 consulta a 2, y un update() con cambios, también de 1 a 2. Un update() o un save() sin cambios no lanza ninguna, con el atributo o sin él. Un create() seguido de refresh() también son 2 consultas, pero hay 3 diferencias:
refresh()pideselect *, todas las columnas de la fila.#[Refreshes]pide solo las de la lista.- Con
refresh()tienes que acordarte en cada sitio que crea o actualiza pedidos: controladores, jobs, comandos o seeders. El atributo está en el modelo y vale para todos. - Los observers y listeners de
createdyupdatedse ejecutan durante elcreate()o elupdate(), antes de que llegues a llamar arefresh(). Sin el atributo, el decreatedrecibestatusytotalanull.
Y no los combines: en un modelo con #[Refreshes], un create() seguido de refresh() son 3 consultas.
Usa la 13.34 o posterior
La 13.33, la primera versión con #[Refreshes], tiene un fallo con increment(): el modelo relee total, pero lo deja marcado como modificado, y el siguiente save() intenta escribir en la columna generada. Es lo que pasa con este código en la 13.33:
$order = Order::create(['reference' => 'A-1004', 'unit_price' => 10]);
$order->increment('quantity');
$order->getDirty(); // ['total' => '20.00']
$order->save();
El save() termina con este error:
Illuminate\Database\QueryException: SQLSTATE[HY000]: General error: 3105 The value specified for generated column 'total' in table 'orders' is not allowed. (Connection: mysql, Host: mysql, Port: 3306, Database: laravel, SQL: update `orders` set `total` = 20.00, `orders`.`updated_at` = 2026-10-14 01:50:06 where `id` = 15)
MySQL no deja escribir en una columna generada. Lo mismo pasa al guardar una copia hecha con replicate(), que también copia total. Los 2 fallos se corrigieron en Laravel 13.34, del 29 de septiembre de 2026, con las PR #61680 y #61751. En la 13.34 y en la 13.36, getDirty() devuelve un array vacío y los 2 save() funcionan.
Cuándo usarlo
Pon en la lista solo las columnas que rellena la base de datos y que lees después de guardar, porque cada create() y cada update() con cambios añade un SELECT, aunque esas columnas no hayan cambiado.
Si el valor por defecto es fijo, como el pending de status, puedes definirlo también en el modelo con la propiedad $attributes o, desde Laravel 13.35, con el método defaults() y el trait HasDefaultAttributes. Así el valor va en el INSERT y no hace falta la consulta, aunque tendrás el valor por defecto en 2 sitios: la migración y el modelo. Para una columna generada no hay alternativa en PHP: con $attributes o con defaults(), total sigue saliendo a null después del create(), y ahí #[Refreshes] es la solución.
Espero que te sirva. Si quieres seguir aprendiendo a trabajar con modelos, no te pierdas el curso Eloquent para Principiantes.
Curso Laravel 12
Completo 2026
El único curso 100% actualizado que incluye Laravel 12, Livewire 3, Vue 3, React 19 e Inertia 2. Aprende con proyectos reales y las últimas funcionalidades.
star Incluido en cualquier suscripción