Cargando…

Esto está tardando más de lo esperado.

Volver al centro de ayuda

Modelos, casts y piezas base

El modelo base, los casts personalizados, los valores por omisión, los enums, las macros, los helpers, las categorías y los stubs sobre los que construyes tus funciones.

Todo lo que agregas al kit se apoya en un conjunto pequeño de piezas que ya existen: un modelo base, cuatro casts para columnas JSON y decimales, un trait para valores por omisión, un trait para enums, unas cuantas macros y helpers, un sistema de categorías reutilizable y los stubs detrás de php artisan make:*. Esta guía lista cada una con el código que escribes para usarla. Léela antes de escribir tu primer modelo.

El modelo base

App\Models\Model es abstracto, extiende el modelo de Eloquent y agrega App\Traits\DatesToUser, que lee y escribe cada atributo datetime y timestamp en la zona horaria de la persona con sesión y lo guarda en UTC. Extiéndelo en cada modelo que crees; el stub de modelo ya lo hace. Qué convierte el trait y cómo se encuentra una zona horaria está en Zonas horarias y fechas.

namespace App\Models;

class Appointment extends Model
{
    protected $guarded = [];
}

Los modelos declaran $guarded, nunca $fillable.

Casts para columnas JSON y decimales

Los cuatro casts viven en app/Casts/. Decláralos en $casts como cualquier cast de Eloquent.

ArrayCast

Convierte una columna JSON en un App\Classes\ArrayObject. A diferencia del cast array de Laravel, un cambio hecho a través del objeto se escribe de vuelta al modelo al instante, así que nunca reasignas el atributo completo.

use App\Casts\ArrayCast;

protected $casts = [
    'extra_data' => ArrayCast::class,
];
$user->extra_data->get('plan', 'free');   // el valor o el valor por omisión
$user->extra_data->has('plan');           // true o false
$user->extra_data->set('plan', 'pro');    // sincronizado al modelo
$user->extra_data->removeKey('legacy');   // sincronizado al modelo
$user->extra_data['plan'] = 'pro';        // el acceso como arreglo también funciona
$user->extra_data->all();                 // arreglo PHP plano
$user->save();

ArrayObject también ofrece add($value) para agregar un valor al final, addValue() como alias de set(), e implementa Livewire\Wireable, así que puede ser una propiedad pública de un componente Livewire. set() y removeKey() sólo tocan el modelo cuando los datos realmente cambian.

JsonCasts

El mismo ArrayObject, más un cast de Eloquent por llave dentro de la columna JSON. La columna lleva JsonCasts::class y cada llave se declara como columna.llave.

use App\Casts\JsonCasts;

protected $casts = [
    'features' => JsonCasts::class,
    'features.starts_at' => 'datetime',
    'features.country' => SomeCast::class,
];

$model->features->get('starts_at') regresa como instancia de Carbon y se serializa de nuevo al guardar. Sólo se soportan llaves de un nivel: un cast declarado como features.a.b se ignora.

ArrayProxyCast

Un atributo virtual que lee y escribe otra columna JSON como arreglo PHP plano, para los lugares que no entienden ArrayObject, como un paquete o una API externa. El argumento después de los dos puntos nombra la columna real.

use App\Casts\{ArrayProxyCast, JsonCasts};

protected $casts = [
    'extra_data' => JsonCasts::class,
    'extra_data_array' => ArrayProxyCast::class . ':extra_data',
];

Leer $user->extra_data_array regresa el JSON crudo de extra_data decodificado, o []. Asignarle un arreglo fusiona en profundidad las llaves nuevas con lo que la columna ya tiene, con array_replace_recursive, y guarda el resultado en extra_data.

SafeDecimal

Un reemplazo del cast nativo decimal. Livewire aplica el cast a un atributo del modelo antes de que corra la validación, y el cast nativo rechaza null y '', así que un campo decimal nullable ligado a un formulario falla en cuanto la persona vacía el input. SafeDecimal deja pasar null y '' y redondea todo lo demás. El argumento del constructor es el número de decimales, 2 por omisión.

use App\Casts\SafeDecimal;

protected $casts = [
    'amount' => SafeDecimal::class,        // 2 decimales
    'rate' => SafeDecimal::class . ':4',   // 4 decimales
];
$model->amount = '';     // se guarda como null
$model->amount = 9.999;  // se guarda como 10.00
$model->amount;          // "10.00", una cadena con decimales fijos

Valores por omisión con HasDefaults

App\Traits\HasDefaults llena los valores faltantes cuando se crea un modelo. Decláralos como un arreglo protegido $defaults, o como un método defaults(): array cuando un valor necesita PHP. Una llave con punto apunta a una llave dentro de un atributo arreglo o con cast JSON.

use App\Traits\HasDefaults;

class Team extends Model
{
    use HasDefaults;

    protected $defaults = [
        'status' => 'draft',
        'extra_data.lang' => 'es',
    ];
}
protected function defaults(): array
{
    return [
        'currency_id' => Currency::query()->value('id'),
        'extra_data.lang' => app()->getLocale(),
    ];
}

Los valores por omisión corren en el evento creating y sólo reemplazan un valor faltante, null o ''; 0, false y cualquier otro valor sobreviven. Para registros que ya existen, llama $model->applyDefaults() y guarda cuando el modelo esté sucio.

Enums con IsEnum

Los valores de estado son enums PHP con respaldo que usan App\Enums\IsEnum. El kit trae cuatro: AnnouncementStatus, CommunicationStatus, CommunicationType y WebhookStatus.

namespace App\Enums;

enum OrderStatus: string
{
    use IsEnum;

    case Draft = 'draft';
    case Paid = 'paid';
}
Llamada Regresa
OrderStatus::options() Una colección indexada por valor con la etiqueta traducida de cada caso, lista para <x-select>
OrderStatus::getOptions('isVisible') Lo mismo, conservando sólo los casos cuyo método isVisible() regresa true
$status->label() El nombre del caso como título, Draft, pasado por __() para poder traducirlo en lang/
$status->is('Paid') Si la instancia es ese caso, por nombre

Pon colores, badges y transiciones en métodos del enum, no en el modelo.

Macros

App\Providers\AppServiceProvider registra estas.

Macro Qué hace
$date->toUserTimezone() Una copia de una instancia de Carbon en la zona horaria de la persona con sesión, UTC para un visitante
@userDate($date) Directiva Blade que imprime una fecha en esa zona horaria como Y-m-d H:i:s
User::tableName() La tabla de un modelo sin instanciarlo, resuelta a través del query builder
$collection->whereNotEmpty() Descarta null, arreglos vacíos, cadenas vacías o de sólo espacios, y cualquier otro valor que empty() de PHP rechace, 0 y false incluidos
$table->getTableName() La tabla sobre la que trabaja un Blueprint de migración
$table->dropColumnWithIndexes($columns) Elimina las llaves foráneas y los índices no primarios que tocan las columnas dadas, y luego las columnas. En SQLite lee PRAGMA index_list para encontrarlos, así que la misma migración corre en las pruebas
Schema::table('orders', function (Blueprint $table) {
    $table->dropColumnWithIndexes(['customer_id', 'coupon_code']);
});

Helpers

app/helpers.php carga cada archivo de app/Helpers/ con un glob. El kit trae base.php; un proyecto agrega su propio archivo, project.php, y un merge con una versión nueva del kit nunca toca el mismo archivo. Cada helper está envuelto en function_exists, así que un proyecto puede redefinir uno cargando su archivo primero.

Helper Qué hace
password_rule() La regla de contraseña por omisión de Laravel con mínimo 8 caracteres, para validación
get_all_classes() Cada nombre de clase en el mapa de clases de Composer más las declaradas, memoizado
get_classes_of($parent, $prefix) Las subclases instanciables de $parent cuyo nombre empieza con $prefix
front_resources($prefix, $sameLevel) Las clases de recursos de Laravel Front bajo App\Front\Resources, más las registradas
front_resources_on_menu($section) Las que responden true a showOnMenu(), pertenecen a la sección y pasan la política viewAny para la persona con sesión
front_resources_grouped_on_menu($section) Lo mismo, agrupadas por su menu_group, General cuando no tienen
class_path(...$parts) Une segmentos con \ en un nombre de clase, aceptando / o \ en la entrada
pwa_public_key() La llave pública VAPID de config('webpush.vapid.public_key') decodificada a un arreglo de bytes, null cuando no está definida
is_ios(), is_android(), is_mobile() Si la petición viene de la app móvil empaquetada: una cookie app-platform o un valor de sesión platform_override para iOS, un referer android-app:// o el valor de sesión para Android
lastUrl() La última URL no Livewire que la persona abrió, guardada en la sesión por un middleware
confirmUserAction($component, $method, $params) El descriptor del modal que pide contraseña o PIN antes de llamar $method; ve Protege acciones sensibles
trackingEvent($name, $payload) Registra un evento de seguimiento de visitantes; no hace nada salvo que config('features.tracking_enabled') esté activo
money_format($value) $ seguido del valor con dos decimales y separadores de miles
normalize_phone_number($number) El número en E.164, adivinando el país por la persona con sesión, el país detectado o config('app.country_code_fallback'); null cuando nada se puede interpretar
locale_url($locale) La URL de la ruta de cambio de idioma, /locale/es
help_markdown($markdown) Renderiza el cuerpo Markdown de una guía del centro de ayuda con las clases del sistema de diseño
billingCoreAvailable(), billingCoreManager(), billingCoreModel($name), billingUserBillingAvailable(), billingUser(), billingDashboardMetrics() Guardas que regresan false o null cuando el paquete de facturación está ausente o deshabilitado, para que el resto del kit llame a facturación sin una dependencia dura
validateGetThumb($url) false para URLs de DiceBear y Gravatar, para que no se regeneren miniaturas de esos avatares

Categorías

Cualquier modelo puede llevar categorías de un tipo con nombre, con un árbol anidado que se administra desde el panel de administración.

El modelo y las tablas

App\Models\Category tiene type, una cadena de hasta 20 caracteres que separa una familia de categorías de otra, name, slug, description, parent_id y order_column. El slug se genera a partir del nombre, es único dentro del tipo y se respeta cuando tú defines uno. Las categorías se ordenan entre sus hermanas, se eliminan con soft delete, se buscan por nombre y quedan en el registro de actividad. parent() y children() dan el árbol.

La tabla pivote categorizables tiene category_id, categorizable_type, categorizable_id y timestamps. La migración 2026_04_05_000001 la crea; la tabla categories también viene con el kit.

Category::getOptions('posts') regresa un arreglo id-a-nombre para un select, con las hijas prefijadas con - y las nietas con -- , en caché por un día y renovado cuando cambia una categoría de ese tipo.

Agrega categorías a un modelo

use App\Traits\HasCategories;

class Post extends Model
{
    use HasCategories;
}

El trait arranca App\Observers\HasCategoriesObserver y agrega:

  • categories(), una relación morphToMany a Category.
  • categories_data, un atributo virtual. Asignarle un arreglo de ids, o una cadena JSON de ids, los guarda en la sesión bajo pending_categories, indexados por la instancia del modelo, así que un modelo que aún no se ha guardado ya puede tener su selección. Los ids se normalizan a enteros únicos mayores a cero.
  • syncCategories(), que el observer llama en saved, sincroniza los ids pendientes al pivote y los borra de la sesión.
$post->categories_data = [1, 3, 5];
$post->save();              // sincronizado en el evento saved
$post->categories_data;     // [1, 3, 5]
$post->categories;          // Collection de Category

Adminístralas en el panel de administración

routes/admin.php registra categories/{type} como admin.categories, renderizada por App\Livewire\Shared\Category. En /admin/categories/posts un administrador crea categorías con nombre, slug y descripción, agrega una hija bajo cualquiera de ellas, edita, reordena con flechas arriba y abajo, y elimina. La miga de pan apunta de regreso a /admin/{type}, así que nombra el tipo igual que la URL del recurso al que pertenece.

Para llegar a esa página desde la lista de un recurso, agrega App\Front\Actions\GoToCategories a index_actions() del recurso:

use App\Front\Actions\GoToCategories;

public function index_actions()
{
    return [
        (new GoToCategories)->setType('posts')->setSection('admin'),
    ];
}

setType($type, $crud = null) nombra el tipo de categoría y, opcionalmente, el recurso al que la página regresa. setSection() es app por omisión; pásale admin, porque el kit sólo registra la página de categorías en routes/admin.php.

Edítalas en un formulario

En un recurso de Laravel Front, usa App\Front\Inputs\Categories sobre el atributo categories_data. Se oculta en las páginas de índice y detalle.

use App\Front\Inputs\Categories as CategoriesInput;

public function fields()
{
    return [
        Inputs\Text::make('Name'),
        CategoriesInput::make('Categories', 'categories_data')
            ->ofType('posts')
            ->multiple(),
    ];
}

Sin ofType() el select lista las categorías de todos los tipos; sin multiple() acepta una sola.

En tu propio formulario Livewire, usa el componente <x-categories>, respaldado por App\View\Components\Categories, que monta el componente App\Livewire\Shared\Inputs\Categories y empuja los ids elegidos a la propiedad ligada de tu componente.

<x-categories
    wire:model="post.categories_data"
    type="posts"
    :is-multiple="true"
    :initial-value="$post->categories_data"
/>

initial-value es la selección actual; show-label="false" oculta la etiqueta "Categorías". Después guarda el modelo y el observer sincroniza el pivote.

Stubs

Los archivos en stubs/ reemplazan a los de Laravel, así que php artisan make:* produce código ya con la forma del kit.

Comando Qué sale
make:model Post Extiende App\Models\Model, usa Searchable, SoftDeletes y WithActivityLog, declara $guarded = [] y $searchable = ['name']
make:migration create_posts_table id(), timestamps() y softDeletes()
make:enum OrderStatus Usa IsEnum, con casos de ejemplo y métodos color() y badge() para adaptar
make:observer PostObserver --model=Post Métodos vacíos created, creating, updated, updating, deleted y deleting
make:policy PostPolicy Extiende App\Policies\BasePolicy con el $name del permiso puesto a la variable del modelo; ve Roles y permisos
make:notification OrderPaid Extiende App\Notifications\Notification con subject(), description() e image(); ve Envía notificaciones
make:job SendInvoice Un job en cola con Queueable; --sync da el sincrónico con Dispatchable
front:resource Post Un recurso de Laravel Front bajo /admin/posts con los inputs ID y Name; --all crea también el modelo, la migración y la política
make:agent Support, make:agent Support --structured, make:tool SearchOrders, make:agent-middleware LogPrompts Las clases de Laravel AI; ve Conecta un proveedor de IA

Edita un stub cuando quieras que cambie cada archivo generado; no hay que registrar nada más.