Skip to content

Rutas, inmutables, secretos y usuarios

Por defecto un modelo tiene doce acciones: la forma correcta para algo que una persona administra desde una pantalla, y la equivocada para buena parte de un sistema real. Una bitácora sólo se agrega, un catálogo sólo se lee, una concesión se otorga y se revoca pero no se edita, y una credencial se lista sin que su secreto salga nunca.

Eso no se resuelve generando todo y borrando lo que sobra: lo borrado a mano queda marcado como editado para siempre, larapack:verify lo señala como deriva y el contrato deja de describir el código. Se declara con cuatro claves: routes, immutable, secret y authenticatable. Sin ellas, un modelo tiene todas las acciones.

Las doce acciones

AcciónVerbo y URINombre de rutaRequestHabilidad de la políticaEvento
policiesGET policiespoliciesPoliciesRequestNinguna: responde con todas
policyGET policypolicyPolicyRequestLa que se pide
indexGET indexindexIndexRequestindex
showGET showshowShowRequestview
createPOST createcreateCreateRequestcreateCreateEvent
updatePUT updateupdateUpdateRequestupdateUpdateEvent
deleteDELETE deletedeleteDeleteRequestdeleteDeleteEvent
restorePOST restorerestoreRestoreRequestrestoreRestoreEvent
forceDeleteDELETE force-deleteforce.deleteForceDeleteRequestforceDeleteForceDeleteEvent
exportPOST exportexportExportRequestexportExportEvent
bulkUpdatePUT bulk-updatebulk.updateBulkUpdateRequestupdate, registro a registroUpdateEvent por registro
bulkDeleteDELETE bulk-deletebulk.deleteBulkDeleteRequestdelete, registro a registroDeleteEvent por registro

La URI completa lleva el prefijo del modelo, api/acme/catalogo/order_line/force-delete, y el nombre completo también: api.acme.catalogo.order_line.force.delete. Todas piden auth:sanctum.

Lo que recibe cada una:

AcciónParámetros
policiesid, opcional: con él, las habilidades sobre ese registro. Responde un objeto con true o false por cada método del controlador y por su habilidad.
policypolicy, una de index, view, viewAny, create, update, delete, restore, forceDelete o export; e id, obligatorio para view, update, delete, restore y forceDelete.
indexLos parámetros de search-surge: paginate, page, orderBy, orderMode, los filtros y managed. Ver support, traits y search-surge.
show<modelo>_id, y opcionalmente load_relations y load_counts, limitados a $loadable_relations y $loadable_counts.
createLos campos; se guardan los de $creatable.
update<modelo>_id y los campos; se guardan los de $updatable.
delete, restore, forceDelete<modelo>_id.
exportLos filtros. Ver Exportar a Excel.
bulkUpdateids y data. Ver Acciones masivas.
bulkDeleteids.

La política generada tiene index y viewAny (con index), view (con show), create, update, delete, restore, forceDelete y export, cada uno según su acción, y todos devuelven false. Su before() deja pasar al usuario cuyo isAdmin() devuelve true, salvo en las habilidades de $exceptAbilities, que empieza con forceDelete: ni el administrador borra para siempre hasta que lo decidas.

routes

json
{ "name": "ApiKey", "routes": { "except": ["update", "restore", "forceDelete"] }, "props": [] }
json
{ "name": "Country", "routes": { "only": ["policies", "policy", "index", "show"] }, "props": [] }
  • only: sólo estas acciones. except: todas menos estas. Nunca las dos.
  • Sin la clave, las doce.

Las acciones que quedan se calculan siempre igual, en este orden:

  1. Se parte de only, o de las doce.
  2. Se quitan las de except.
  3. Si el modelo es immutable, se quitan update, delete, restore, forceDelete, bulkUpdate y bulkDelete.
  4. Se quita bulkUpdate si no queda update, y bulkDelete si no queda delete: una masiva usa la política y las reglas de su individual.

Por eso "except": ["delete"] quita también el borrado masivo, sin error. Pedir una masiva en only sin su individual sí es un error, porque alguien la pidió expresamente.

Qué cuelga de cada acción

Quitar una acción quita todo lo que depende de ella:

PiezaQué se quita
Rutas y controladorSu ruta, y su método y su import en el controlador. Un modelo sin ninguna acción no tiene controlador ni archivo de rutas.
RequestSu archivo.
EventoSu evento y sus listeners (create, update, delete, restore, forceDelete y export).
PolíticaSu método.
ObserverSu handler: updated, deleted, restored o forceDeleted. created está siempre.
StoragerestoreModel(), sin restore.
Modelo y migraciónSoftDeletes y $table->softDeletes(), si no queda ninguna de delete, restore y forceDelete.
RecursoSus acciones de fila: «Show» sin show, «Edit» sin update o sin show, «Delete» sin delete.
ExportaciónLa clase de exportación, la vista de Excel, la notificación y su listener, sin export.
TestSu test.
Contrato JSSu función: getPolicies, getPolicy, indexModel, showModel, createModel, updateModel, deleteModel, restoreModel, forceDeleteModel, exportModel, bulkUpdateModels y updateField, bulkDeleteModels.
Barra de la tabla y paleta«Create» sin create; «Export» sin export; las acciones de la selección sin bulkUpdate ni bulkDelete.
StorefetchIndex, fetchOne, fetchPolicies, create, update y remove, según su acción.
Vistas y formulariosLos que necesitan esa acción (ver el módulo), la ruta hija, el drawer y la entrada del menú de la ficha.

Lo que queda siempre: el modelo, sus traits, sus filtros, la política con su before(), el observer, la factory, la migración, el recurso y el archivo de test.

Las vistas necesitan index y policies

Las vistas cuelgan de la ruta del índice, y la tabla consulta las políticas para decidir qué acciones ofrece. La edición cuelga además de la ruta de detalle, así que necesita show. Con --vue o --react, la validación avisa de lo que falte.

immutable

json
{
    "name": "AuditEvent",
    "immutable": true,
    "routes": { "only": ["policies", "index", "show", "export"] },
    "props": [
        { "name": "action", "type": "string", "datatable": true },
        { "name": "details", "type": "longText", "cast": "json" }
    ]
}

Una fila inmutable, una vez creada, no se modifica ni se borra.

  • Quita update, delete, restore, forceDelete, bulkUpdate y bulkDelete, con todo lo que cuelga de ellas.
  • Crear sigue permitido: una fila inmutable nace (un consentimiento, un movimiento contable, un registro de envío). Si tampoco debe crearse por la API, quítalo con routes, como en el ejemplo.
  • El modelo se defiende solo. Quitar las rutas impide entrar por HTTP, pero no que el propio código llame a save() o a delete(). El modelo registra dos guardas:
php
protected static function booted(): void
{
    $refuse = static function (): never {
        throw new \LogicException('AuditEvent es inmutable: sus filas no se modifican ni se borran.');
    };

    static::updating($refuse);
    static::deleting($refuse);
}
  • El test lo comprueba: que no hay rutas de escritura registradas y que modificar o borrar un registro lanza LogicException.
  • larapack:verify lo vigila con immutable-write: una ruta, un método, un request o un componente de escritura, o el modelo sin sus guardas.

Lo que la guarda no cubre

Una actualización masiva desde el query builder, como AuditEvent::query()->update([...]), no carga modelos ni dispara eventos, así que no pasa por booted(). Si el dominio lo necesita garantizado también ahí, hazlo en la base de datos.

secret

json
{
    "name": "ApiKey",
    "routes": { "except": ["update", "restore", "forceDelete"] },
    "props": [
        { "name": "label", "type": "string", "form": true, "form_component": "TextInputComponent", "form_submit": true, "datatable": true },
        { "name": "token_hash", "type": "string", "secret": true }
    ]
}

Una columna secreta se escribe pero no se lee: el hash de un token, una credencial.

  • Va a $hidden del modelo. El recurso generado devuelve parent::toArray(), así que no sale por la API, ni en cualquier otra serialización.
  • Nunca sale en la tabla ni en la exportación. datatable y exports_cols se ponen en false aunque no lo escribas; escribirlos en true es un error de validación.
  • Sigue siendo asignable: fillable, creatable y updatable conservan su valor.
  • No genera acciones masivas por valor, no se edita en la celda y no puede ser display.
  • larapack:verify lo vigila con secret-exposed: una columna secreta fuera de $hidden, en $export_cols, nombrada en el recurso o como columna de la tabla.

Nunca «sólo para depurar»

Nombrar un secreto en el recurso lo expone aunque esté en $hidden, y verify falla con razón. Si necesitas ver el valor, consúltalo en la base de datos, no por la API.

authenticatable

json
{
    "name": "User",
    "authenticatable": true,
    "routes": { "except": ["create"] },
    "props": [
        { "name": "name", "type": "string", "datatable": true },
        { "name": "email", "type": "string", "datatable": true },
        { "name": "email_verified_at", "type": "timestamp", "nullable": true, "fillable": false },
        { "name": "password", "type": "string", "updatable": false }
    ]
}

El modelo es el usuario que inicia sesión, con la misma arquitectura que cualquier otro: API, políticas, tests y su módulo en el administrador. Lo que cambia:

  • Hereda de Illuminate\Foundation\Auth\User y usa Notifiable y HasApiTokens, así que la aplicación necesita laravel/sanctum.
  • password y remember_token van a $hidden, y quedan fuera de la tabla y de la exportación aunque se declaren.
  • Si están declarados y no tienen un cast propio, email_verified_at se castea a datetime y password a hashed: la contraseña se guarda cifrada sin que nadie lo haga a mano.
  • isAdmin() compara el correo, sin distinguir mayúsculas, con config('auth.admins'). Es lo que consulta el before() de cada política generada, en este modelo y en todos los demás.
  • Tiene que declarar email y password: sin ellos, larapack:validate falla.

La lista de administradores la define la aplicación en config/auth.php. La aplicación base la lee de ADMIN_EMAILS:

php
'admins' => array_values(array_filter(array_map('trim', explode(',', env('ADMIN_EMAILS'))))),

remember_token no se añade como columna

LaraPack no añade remember_token a props. En una aplicación, la tabla users la crea la migración de Laravel, que LaraPack no altera: ver Migraciones que no generó LaraPack.

isAdmin() es un punto de partida: cámbialo por tu sistema de roles cuando lo tengas. Cómo lo usa la aplicación base está en Acceso y usuarios.

display

La columna que nombra a un registro en pantalla: el título de su ficha, su miga de pan y el título de la pestaña del navegador.

json
{ "name": "Product", "display": "sku", "props": [ { "name": "sku", "type": "string" } ] }

Sin la clave se elige sola:

  1. name, si existe;
  2. si no, title;
  3. si no, la primera columna de texto (string, char, text, tinyText, mediumText o longText) que no sea secret ni payload, prefiriendo las que tienen datatable;
  4. si no hay ninguna, id.

Una columna que no existe o una secret son un error de validación. Un modelo generado sin laraimport, con larapack:full-model, se nombra por name.

Acciones masivas

bulkUpdate y bulkDelete son la acción individual aplicada a varios registros.

En la API

bulkUpdatebulkDelete
PeticiónPUT bulk-update con ids y dataDELETE bulk-delete con ids
Validaciónids obligatorio, entre 1 y 500 enteros distintos; data obligatorio, un objeto con al menos un campo; cada regla de UpdateRequest, salvo la del identificador, como data.<campo> y precedida de sometimesids obligatorio, entre 1 y 500 enteros distintos
AutorizaciónLa habilidad update sobre cada registroLa habilidad delete sobre cada registro
Qué haceupdateModel() en cada registro, con data: sólo $updatable y las metas editablesdeleteModel() en cada registro
  • Todo o nada. Si un registro no se puede tocar, responde 403 y no se toca ninguno. Si un id no existe, responde 404. El trabajo va dentro de una transacción.
  • Los eventos salen al final, uno por registro, cuando el cambio ya es firme.
  • Responde la colección de recursos.
  • PoliciesRequest traduce bulkUpdate a update y bulkDelete a delete al responder las habilidades.

sometimes hace que un campo que no llega no se exija ni se toque: se puede cambiar un solo campo en muchos registros.

En la tabla

El contrato declara las acciones de la selección en bulkActions():

js
export const bulkActions = () => [
    {
        id: 'status-published',
        name: t('Status') + ': ' + t('Published'),
        success: t('Records updated'),
        callback: 'bulkUpdateModels',
        icon: 'edit',
        params: { status: 'published' },
    },
    {
        id: 'bulkDelete',
        name: t('Delete'),
        success: t('Records deleted'),
        callback: 'bulkDeleteModels',
        icon: 'delete',
        danger: true,
        params: {},
    },
]
  • Una acción por valor de enum, si el modelo tiene bulkUpdate y la propiedad tiene enum, es updatable y no es secret: «Estado: Publicado» pone ese valor en todos los seleccionados.
  • «Delete» con bulkDelete, que pregunta antes con la confirmación del tema.
  • La tabla llama a model[callback](ids, filas, params) y después recarga. Los ids incluyen los seleccionados en otras páginas.
  • Las casillas de selección sólo salen si bulkActions() devuelve alguna acción.

Edición en la celda

Una columna de texto de una línea que el formulario ya edita se edita en su celda: clic, se escribe, y Enter o salir guarda sólo ese campo.

Una columna se edita en la celda si se cumple todo esto:

  • el modelo tiene bulkUpdate;
  • la propiedad tiene datatable, form y updatable;
  • su tipo es string o char y su componente, TextInputComponent;
  • no es secret ni tiene enum: un enum tiene sus acciones masivas.

El contrato lo declara así:

js
{
    id: 'title',
    value: t('Title'),
    sortable: true,
    html: false,
    component: 'ClickToEdit',
    parser: (value, row) => ({ value, label: t('Title'), save: (next) => updateField(row.id, 'title', next) }),
},
  • updateField(id, campo, valor) llama a bulkUpdateModels([id], [], { campo: valor }), así que pasa por la política de update y por las reglas de UpdateRequest.
  • Si la API lo rechaza, lanza un error con el mensaje de data.<campo> y la celda lo enseña sin cerrarse.
  • El contrato sólo nombra el componente. Cada widget DataTable lo aporta con dataTableComponents: ClickToEditComponent en Vue y, en React, un envoltorio que pasa save como onSave.

Las mismas primitivas sin laraimport

larapack:full-model acepta las mismas decisiones para un modelo suelto:

bash
php artisan larapack:full-model AuditEvent --only=policies,index,show --immutable
php artisan larapack:full-model ApiKey --except=update,restore,forceDelete

--only y --except no van juntas, una acción desconocida es un error y --immutable no admite escrituras en --only. secret y display sólo se declaran en el laraimport.

En los dos casos, la forma de un modelo que se aparta de la de siempre queda en models.<Model>.declaration del manifiesto, que es lo que lee larapack:verify.