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ónmorphToManyaCategory.categories_data, un atributo virtual. Asignarle un arreglo de ids, o una cadena JSON de ids, los guarda en la sesión bajopending_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 ensaved, 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.