ORM y Query Builder
Aprender el ORM y el constructor de consultas para modelos, relaciones, multi‑tenancy, hooks y acceso directo a la base de datos.
Aprender el ORM y el constructor de consultas para modelos, relaciones, multi‑tenancy, hooks y acceso directo a la base de datos.
Booknetic utiliza un ORM personalizado de estilo ActiveRecord construido sobre $wpdb de WordPress. Proporciona una forma práctica y expresiva de trabajar con registros de base de datos usando modelos, un query builder encadenable, objetos collection, relaciones entre modelos, scopes globales, hooks del ciclo de vida y aislamiento automático multi-tenant.
Para el desarrollo de addons y de funcionalidades internas, este ORM es la capa principal de base de datos utilizada en todo Booknetic. Te permite escribir consultas legibles basadas en modelos en lugar de construir SQL manualmente para operaciones comunes.
El ORM está construido alrededor de cuatro clases principales:
| Clase | Namespace | Rol |
|---|---|---|
Model | BookneticApp\Providers\DB\Model | Clase base para todos los modelos. Define tabla, relaciones, scopes y hooks del ciclo de vida. |
QueryBuilder | BookneticApp\Providers\DB\QueryBuilder | Query builder encadenable. Gestiona WHERE, JOIN, ORDER BY y operaciones similares de consulta. |
Collection | BookneticApp\Providers\DB\Collection | Envuelve una sola fila de base de datos. Proporciona acceso por propiedades, acceso tipo array y carga de relaciones. |
DB | BookneticApp\Providers\DB\DB | Wrapper de bajo nivel para base de datos sobre $wpdb de WordPress. Gestiona consultas raw y resolución de nombres de tabla. |
El flujo de datos se ve así:
Model::query() → QueryBuilder → DB (raw SQL) → Collection (result)Los modelos representan tablas de base de datos y actúan como punto de entrada para la mayoría de las operaciones del ORM.
Cada tabla de base de datos está representada por una clase de modelo dentro de app/Models/. Un modelo extiende BookneticApp\Providers\DB\Model:
<?php
namespace BookneticApp\Models;
use BookneticApp\Providers\DB\Model;
use BookneticApp\Providers\DB\MultiTenant;
class Customer extends Model
{
use MultiTenant;
public static $relations = [
'category' => [CustomerCategory::class, 'id', 'category_id']
];
}Eso es suficiente para que el modelo sea funcional. No se requiere definición explícita del nombre de tabla a menos que quieras sobrescribir el valor predeterminado.
Los nombres de tabla se generan automáticamente a partir del nombre de la clase usando esta regla:
PascalCase → snake_case + plural
| Clase del modelo | Tabla generada | Tabla completa en DB |
|---|---|---|
Customer | customers | wp_bkntc_customers |
Appointment | appointments | wp_bkntc_appointments |
ServiceExtra | service_extras | wp_bkntc_service_extras |
AppointmentPrice | appointment_prices | wp_bkntc_appointment_prices |
Todas las tablas ORM de Booknetic usan el prefijo {wp_base_prefix}bkntc_, como por ejemplo wp_bkntc_.
Si lo necesitas, puedes sobrescribir el nombre de tabla generado:
class MyModel extends Model
{
protected static $tableName = 'custom_table_name';
}Los siguientes son algunos de los modelos integrados más utilizados:
| Modelo | Tabla | Multi-Tenant | Descripción |
|---|---|---|---|
Appointment | appointments | Sí | Reservas |
AppointmentExtra | appointment_extras | No | Extras asociados a citas |
AppointmentPrice | appointment_prices | No | Desglose de precios por cita |
Customer | customers | Sí | Registros de clientes |
CustomerCategory | customer_categories | Sí | Agrupación de clientes |
Service | services | Sí | Servicios disponibles |
ServiceCategory | service_categories | Sí | Agrupación de servicios |
ServiceExtra | service_extras | Sí | Extras adicionales para servicios |
ServiceStaff | service_staffs | No | Relación servicio-personal |
Staff | staffs | Sí | Miembros del personal |
Location | locations | Sí | Ubicaciones del negocio |
Holiday | holidays | Sí | Días no laborables |
SpecialDay | special_days | Sí | Sobrescrituras especiales de horario |
Timesheet | timesheets | Sí | Horarios de trabajo |
Workflow | workflows | Sí | Reglas de automatización |
WorkflowLog | workflow_logs | Sí | Registros de ejecución de automatizaciones |
Appearance | appearances | Sí | Configuración del tema del panel de reservas |
Data | data | No | Almacenamiento meta clave-valor |
Translation | translations | No | Contenido multiidioma |
El query builder es la interfaz principal para recuperar, filtrar, unir, actualizar y eliminar registros.
Cada consulta comienza desde un modelo:
// Opción 1: usando query()
$query = Customer::query();
// Opción 2: llamar métodos directamente sobre el modelo (crea QueryBuilder automáticamente)
$customer = Customer::where('email', '[email protected]')->fetch();
// Opción 3: obtener por ID
$customer = Customer::get(5);// Devuelve un objeto Collection, o null si no se encuentra
$customer = Customer::query()
->where('email', '[email protected]')
->fetch();
echo $customer->first_name;
echo $customer->email;// Devuelve un array de objetos Collection
$customers = Customer::query()
->where('gender', 'male')
->orderBy(['first_name' => 'ASC'])
->fetchAll();
foreach ($customers as $customer) {
echo $customer->first_name . ' ' . $customer->last_name;
}// Devuelve un array de arrays asociativos (sin envolver en Collection)
$rows = Customer::query()
->where('gender', 'male')
->fetchAllAsArray();$appointment = Appointment::get(42);
// Equivalente a: Appointment::query()->where('id', 42)->fetch();// Igualdad
Customer::where('email', '[email protected]')->fetch();
// Con operador
Appointment::where('starts_at', '>', '2025-01-01 00:00:00')->fetchAll();
// Múltiples condiciones (AND)
Appointment::query()
->where('status', 'approved')
->where('staff_id', 3)
->fetchAll();// Pasar múltiples condiciones como array
Appointment::where([
'status' => 'approved',
'staff_id' => 3
])->fetchAll();Customer::query()
->where('first_name', 'John')
->orWhere('first_name', 'Jane')
->fetchAll();// Atajo para where('id', $value)
Customer::whereId(5)->fetch();// IS NULL
Appointment::query()->whereIsNull('recurring_id')->fetchAll();
// IS NOT NULL
Appointment::query()->whereIsNotNull('note')->fetchAll();
// Usando where() directamente
Appointment::where('tenant_id', 'is', null)->fetchAll();
Appointment::where('tenant_id', 'is not', null)->fetchAll();// Pasar un array como valor
Appointment::where('status', ['approved', 'pending'])->fetchAll();
// NOT IN
Appointment::where('status', 'not in', ['canceled', 'rejected'])->fetchAll();// Envuelve automáticamente con %...%
Customer::query()->like('first_name', 'John')->fetchAll();
// Genera: WHERE first_name LIKE '%John%'
// OR LIKE
Customer::query()
->like('first_name', 'John')
->orLike('last_name', 'John')
->fetchAll();Para columnas con valores separados por comas:
Service::query()->whereFindInSet('staff_ids', 5)->fetchAll();
// Genera: WHERE FIND_IN_SET('5', staff_ids)Customer::query()
->where(function ($query) {
$query->where('first_name', 'John')
->orWhere('first_name', 'Jane');
})
->where('gender', 'male')
->fetchAll();
// Genera: WHERE (first_name = 'John' OR first_name = 'Jane') AND gender = 'male'// Obtener clientes que tienen al menos una cita
$appointmentCustomers = Appointment::query()->select('customer_id', true);
Customer::query()
->where('id', 'in', $appointmentCustomers)
->fetchAll();
// Genera: WHERE id IN (SELECT customer_id FROM wp_bkntc_appointments ...)// Seleccionar columnas específicas
Customer::query()
->select(['first_name', 'last_name', 'email'])
->fetchAll();
// Reemplazar todas las selecciones previas
Customer::query()
->select(['first_name', 'email'], true)
->fetchAll();
// Subconsulta como columna
$countQuery = Appointment::query()
->select(['COUNT(*)'], true)
->where('customer_id', DB::field('id', Customer::getTableName()));
Customer::query()
->selectSubQuery($countQuery, 'appointment_count')
->fetchAll();
// Ahora cada resultado tiene ->appointment_countAppointment::query()
->orderBy(['starts_at' => 'DESC'])
->fetchAll();
// Múltiples columnas de ordenación
Appointment::query()
->orderBy(['status' => 'ASC', 'starts_at' => 'DESC'])
->fetchAll();Appointment::query()
->select(['status', 'COUNT(*) as count'])
->groupBy(['status'])
->fetchAll();$page = 2;
$perPage = 10;
$appointments = Appointment::query()
->orderBy(['starts_at' => 'DESC'])
->limit($perPage)
->offset(($page - 1) * $perPage)
->fetchAll();// Count
$count = Appointment::query()
->where('status', 'approved')
->count();
// Count con GROUP BY
$count = Appointment::query()
->groupBy(['status'])
->countGroupBy();
// Sum
$total = Appointment::query()
->where('payment_status', 'paid')
->sum('paid_amount');$appointments = Appointment::query()
->leftJoin('customers', ['first_name', 'last_name', 'email'], 'customer_id', 'id')
->where('status', 'approved')
->fetchAll();
// Se puede acceder a las columnas unidas con prefijo de tabla:
foreach ($appointments as $apt) {
echo $apt->customers_first_name; // Alias de columna: {table}_{column}
echo $apt->customers_email;
}Los métodos de join usan esta firma:
->leftJoin($joinTo, $selectFields, $field1, $field2, $unselectOldFields, $alias)
->rightJoin(...)
->innerJoin(...)| Parámetro | Tipo | Descripción |
|---|---|---|
$joinTo | string | Nombre de tabla o nombre de clase del modelo |
$selectFields | array|string | Columnas a seleccionar de la tabla unida |
$field1 | string|null | Campo de join de la tabla unida |
$field2 | string|null | Campo de join de la tabla principal |
$unselectOldFields | bool | Si es true, elimina la selección de la tabla principal |
$alias | string|null | Alias para la tabla unida |
Si una relación está definida en el modelo, puedes hacer join usando el nombre de la relación sin definir manualmente los campos:
// Appointment tiene definida la relación 'customer'
$appointments = Appointment::query()
->leftJoin('customer', ['first_name', 'last_name'])
->fetchAll();
// Usa automáticamente el mapeo de campos de la relaciónAppointment::query()
->leftJoinSelf('parent', ['status'], 'id', 'recurring_id')
->fetchAll();
// Une la tabla appointments consigo misma usando el alias 'parent'Appointment::query()
->leftJoin('staffs', ['name'], 'staff_id', 'id', false, 'assigned_staff')
->fetchAll();
// Acceso: $appointment->assigned_staff_nameCustomer::query()->insert([
'first_name' => 'John',
'last_name' => 'Doe',
'email' => '[email protected]',
'phone_number' => '+1234567890'
]);
// Obtener el ID insertado
$newId = DB::lastInsertedId();
// o
$newId = Customer::lastId();// Actualizar por ID
Customer::query()
->whereId(5)
->update(['first_name' => 'Jane']);
// Actualizar con condiciones
Appointment::query()
->where('status', 'pending')
->where('starts_at', '<', '2025-01-01 00:00:00')
->update(['status' => 'canceled']);Usa DB::field() cuando quieras que un valor de actualización haga referencia a otra columna en lugar de tratarse como un string simple:
Appointment::query()
->whereId($id)
->update([
'paid_amount' => DB::field('paid_amount + 100')
]);
// Genera: SET paid_amount = paid_amount + 100// Eliminar por ID
Customer::query()->whereId(5)->delete();
// Eliminar con condiciones
Appointment::query()
->where('status', 'canceled')
->where('starts_at', '<', '2024-01-01 00:00:00')
->delete();Puedes inspeccionar el SQL generado sin ejecutar la consulta:
$sql = Appointment::query()
->where('status', 'approved')
->orderBy(['starts_at' => 'DESC'])
->limit(10)
->toSql();
error_log($sql);
// Output: SELECT * FROM wp_bkntc_appointments WHERE status = 'approved' ORDER BY starts_at DESC LIMIT 0, 10Cuando fetch() devuelve un único registro, se devuelve como un objeto Collection. Cuando se usa fetchAll(), devuelve un array de objetos Collection.
$customer = Customer::get(1);
// Acceso por propiedad
echo $customer->first_name;
echo $customer->email;
// Acceso tipo array
echo $customer['first_name'];
echo $customer['email'];
// Convertir a array simple
$array = $customer->toArray();Los modelos pueden definir atributos virtuales o calculados usando métodos get{AttributeName}Attribute.
Ejemplo en un modelo Customer:
public function getFullNameAttribute($customer)
{
return $customer->first_name . ' ' . $customer->last_name;
}
// Uso:
$customer = Customer::get(1);
echo $customer->full_name; // "John Doe"Ejemplo en un modelo Appointment:
public static function getStatusNameAttribute($appointmentInf)
{
$statuses = Helper::getAppointmentStatuses();
return $statuses[$appointmentInf->status]['title'] ?? $appointmentInf->status;
}
// Uso:
$appointment = Appointment::get(1);
echo $appointment->status_name; // "Approved"Si fetch() no encuentra una fila coincidente, devuelve null, no una collection vacía. Siempre debes manejar ese caso:
$customer = Customer::where('email', '[email protected]')->fetch();
if ($customer) {
echo $customer->first_name;
}Las relaciones permiten moverte entre modelos relacionados y también sirven para el mapeo automático en joins.
Las relaciones se definen en el array $relations del modelo:
public static $relations = [
'relationName' => [RelatedModel::class, 'foreignKey', 'localKey']
];| Parámetro | Descripción |
|---|---|
RelatedModel::class | La clase del modelo relacionado |
foreignKey | La columna en la tabla relacionada |
localKey | La columna en esta tabla |
Ejemplo desde un modelo Appointment:
public static $relations = [
'extras' => [AppointmentExtra::class, 'appointment_id', 'id'],
'location' => [Location::class, 'id', 'location_id'],
'service' => [Service::class, 'id', 'service_id'],
'staff' => [Staff::class, 'id', 'staff_id'],
'customer' => [Customer::class, 'id', 'customer_id'],
'prices' => [AppointmentPrice::class, 'appointment_id', 'id'],
];Llamar a una relación como método sobre una collection devuelve un QueryBuilder para los registros relacionados:
$appointment = Appointment::get(1);
// Devuelve un QueryBuilder — llama a fetch()/fetchAll() para obtener resultados
$customer = $appointment->customer()->fetch();
$extras = $appointment->extras()->fetchAll();
$service = $appointment->service()->fetch();
echo $customer->first_name;
echo $service->name;Para mejor rendimiento y evitar consultas N+1, usa joins:
$appointments = Appointment::query()
->leftJoin('customer', ['first_name', 'last_name', 'email'])
->leftJoin('service', ['name', 'price', 'duration'])
->leftJoin('staff', ['name'])
->where('status', 'approved')
->orderBy(['starts_at' => 'DESC'])
->limit(20)
->fetchAll();
foreach ($appointments as $apt) {
// Las columnas unidas usan la nomenclatura {relation}_{column}
echo $apt->customer_first_name;
echo $apt->service_name;
echo $apt->staff_name;
echo $apt->starts_at;
}Las relaciones también habilitan el mapeo automático de campos en joins, por lo que no es necesario repetir manualmente las claves foráneas y locales:
// Estas dos son equivalentes:
Appointment::query()->leftJoin('customer', ['first_name'], 'customer_id', 'id');
Appointment::query()->leftJoin('customer', ['first_name']); // Usa la definición de la relaciónLos scopes globales aplican condiciones automáticamente a cada consulta de un modelo. Así es como Booknetic gestiona funciones como el aislamiento por tenant y la restricción de acceso basada en personal.
class Appointment extends Model
{
use MultiTenant {
booted as private tenantBoot;
}
public static function booted()
{
self::tenantBoot(); // Añade el scope tenant_id
// Los miembros del personal solo pueden ver sus propias citas
self::addGlobalScope('staff_id', function (QueryBuilder $builder, $queryType) {
if (!Permission::isBackEnd() || Permission::isAdministrator()) {
return; // Sin restricción para frontend o administradores
}
$builder->where('staff_id', Permission::myStaffId());
});
}
}Cada consulta sobre Appointment ahora incluye automáticamente:
WHERE tenant_id = {current_tenant} desde MultiTenantWHERE staff_id = {current_staff} para usuarios backend no administradores$queryTypeEl callback del scope recibe el tipo de consulta, por lo que puedes variar el comportamiento según la operación:
| queryType | Cuándo |
|---|---|
'select' | Consultas SELECT como fetch, fetchAll, count y operaciones similares |
'insert' | Consultas INSERT |
'update' | Consultas UPDATE |
'delete' | Consultas DELETE |
Puedes desactivar un scope específico cuando lo necesites:
// Omitir un scope específico
Appointment::query()
->withoutGlobalScope('staff_id')
->fetchAll();
// Omitir el scope tenant
Appointment::query()
->noTenant()
->fetchAll();El trait MultiTenant proporciona aislamiento automático por tenant en modo SaaS.
Cuando un modelo usa MultiTenant:
WHERE tenant_id = {current_tenant}tenant_id = {current_tenant}WHERE tenant_id IS NULLclass Customer extends Model
{
use MultiTenant;
}
// En modo SaaS, esta consulta se convierte automáticamente en:
// SELECT * FROM wp_bkntc_customers WHERE tenant_id = 5
Customer::query()->fetchAll();Usa noTenant() cuando intencionalmente necesites consultar todos los tenants, como en lógica de super admin:
// Consultar TODOS los clientes de todos los tenants
$allCustomers = Customer::query()
->noTenant()
->fetchAll();Algunas tablas no tienen columna tenant_id, como appointment_extras, appointment_prices y data. Sus modelos simplemente no usan el trait MultiTenant, por lo que no se aplica filtrado por tenant.
Los modelos soportan hooks del ciclo de vida que se disparan alrededor de operaciones CRUD:
| Hook | Se dispara cuando | ¿Puede cancelar? |
|---|---|---|
onCreating($closure) | Antes de INSERT | Sí |
onCreated($closure) | Después de INSERT | No |
onUpdating($closure) | Antes de UPDATE | Sí |
onUpdated($closure) | Después de UPDATE | No |
onDeleting($closure) | Antes de DELETE | Sí |
onDeleted($closure) | Después de DELETE | No |
onRetrieving($closure) | Antes de SELECT | No |
onRetrieved($closure) | Después de SELECT, por fila | No |
Si un hook before como onCreating, onUpdating o onDeleting devuelve false, la operación se cancela.
// Cancelar la eliminación de un registro específico
Customer::onDeleting(function ($customerId) {
if ($customerId === 1) {
return false; // Evitar eliminar el cliente #1
}
});
// Registrar cada nueva cita
Appointment::onCreated(function (QueryBuilder $queryBuilder) {
$data = $queryBuilder->getProperties();
error_log('Nueva cita para el servicio: ' . $data['service_id']);
});Establece $timeStamps = true para rellenar automáticamente created_at y updated_at:
class MyModel extends Model
{
protected static bool $timeStamps = true;
}En una inserción, tanto created_at como updated_at se rellenan automáticamente. En una actualización, updated_at se actualiza automáticamente.
Establece $enableOwnershipFields = true para rellenar automáticamente created_by y updated_by con el ID del usuario actual de WordPress:
class MyModel extends Model
{
protected static bool $enableOwnershipFields = true;
}Cada modelo incluye soporte para almacenar datos arbitrarios clave-valor mediante la tabla data. Esto es útil cuando se necesita guardar datos específicos de addons sin cambiar el esquema principal de la tabla.
// Establecer meta datos para un cliente
Customer::setData($customerId, 'preferred_language', 'en');
// Obtener meta datos
$lang = Customer::getData($customerId, 'preferred_language', 'default_value');
// Eliminar meta datos
Customer::deleteData($customerId, 'preferred_language');$customer = Customer::get(5);
// Establecer
$customer->setData('vip_level', 'gold');
// Obtener
$level = $customer->getData('vip_level', 'standard');
// Eliminar
$customer->deleteData('vip_level');Internamente, estos datos se almacenan en la tabla wp_bkntc_data:
| Columna | Descripción |
|---|---|
table_name | Tabla de origen, como customers |
row_id | ID del registro |
data_key | Clave |
data_value | Valor |
Cuando la capa ORM no es suficiente o cuando necesitas acceso directo a las herramientas de base de datos de WordPress, puedes trabajar directamente con la clase DB.
use BookneticApp\Providers\DB\DB;
// Obtener el nombre completo de la tabla con prefijo
DB::table('appointments'); // "wp_bkntc_appointments"
DB::table(Appointment::class); // "wp_bkntc_appointments"// Consulta preparada (segura frente a SQL injection)
$sql = DB::raw("SELECT * FROM %s WHERE status = %s", [
DB::table('appointments'),
'approved'
]);
// Ejecutar vía $wpdb de WordPress
$results = DB::DB()->get_results($sql, ARRAY_A);// Obtener la instancia $wpdb de WordPress
$wpdb = DB::DB();
// Usar cualquier método de $wpdb
$wpdb->query("...");
$wpdb->get_row("...");
$wpdb->get_results("...");// Activar caché — consultas idénticas devuelven resultados cacheados
DB::enableCache();
// Tus consultas aquí...
$a = Customer::get(1); // Golpea la base de datos
$b = Customer::get(1); // Devuelve resultado cacheado
// Desactivar y limpiar caché
DB::disableCache();Customer::query()->insert([...]);
$id = DB::lastInsertedId();
// o
$id = Customer::lastId();Usa DB::field() cuando una consulta necesite referenciar un nombre de columna en lugar de un valor string literal:
// Comparar dos columnas
Appointment::where('starts_at', '>', DB::field('busy_from'))->fetchAll();
// Actualizar usando el valor de otra columna
Appointment::query()->whereId($id)->update([
'paid_amount' => DB::field('paid_amount + 50')
]);Los modelos que soportan contenido multiidioma usan el trait Translator:
class Service extends Model
{
use Translator, MultiTenant;
protected static $translations = ['name', 'note'];
}Para recuperar valores traducidos, llama a withTranslations():
// Obtener con traducciones aplicadas (según la locale actual)
$services = Service::query()
->withTranslations()
->fetchAll();
echo $services[0]->name; // Devuelve el nombre traducido si existeEstos son algunos patrones de uso comunes del ORM en el desarrollo de Booknetic.
$page = 1;
$perPage = 15;
$query = Appointment::query()
->where('status', 'approved')
->orderBy(['starts_at' => 'DESC']);
$total = $query->count();
$appointments = $query
->limit($perPage)
->offset(($page - 1) * $perPage)
->fetchAll();$report = Appointment::query()
->select([
'service_id',
'COUNT(*) as total_bookings',
'SUM(paid_amount) as total_revenue'
], true)
->leftJoin('service', ['name'])
->where('status', 'approved')
->where('starts_at', '>=', '2025-01-01 00:00:00')
->groupBy(['service_id'])
->orderBy(['total_bookings' => 'DESC'])
->fetchAll();
foreach ($report as $row) {
echo $row->service_name . ': ' . $row->total_bookings . ' bookings, $' . $row->total_revenue;
}$query = Appointment::query()
->orderBy(['starts_at' => 'DESC']);
if ($statusFilter) {
$query->where('status', $statusFilter);
}
if ($staffId) {
$query->where('staff_id', $staffId);
}
if ($search) {
$query->leftJoin('customer', ['first_name', 'last_name']);
$query->like('customers.first_name', $search);
}
$results = $query->limit(20)->fetchAll();$exists = Customer::query()
->where('email', '[email protected]')
->count() > 0;$customer = Customer::where('email', $email)->fetch();
if (!$customer) {
Customer::query()->insert([
'first_name' => $firstName,
'last_name' => $lastName,
'email' => $email
]);
$customer = Customer::get(Customer::lastId());
}El ORM y query builder de Booknetic proporcionan una capa completa de base de datos basada en modelos, con construcción expresiva de consultas, soporte para relaciones, aislamiento automático por tenant, hooks del ciclo de vida, soporte de traducciones, metadatos clave-valor y acceso directo de bajo nivel a la base de datos cuando es necesario.
| Operación | Código |
|---|---|
| Obtener por ID | Model::get($id) |
| Encontrar uno | Model::where('field', 'value')->fetch() |
| Encontrar varios | Model::where('field', 'value')->fetchAll() |
| Count | Model::where('field', 'value')->count() |
| Sum | Model::where('field', 'value')->sum('column') |
| Insertar | Model::query()->insert([...]) |
| Actualizar | Model::whereId($id)->update([...]) |
| Eliminar | Model::whereId($id)->delete() |
| Join | Model::query()->leftJoin('table', ['cols'], 'fk', 'pk')->fetchAll() |
| SubQuery | Model::query()->selectSubQuery($subQB, 'alias')->fetchAll() |
| Sin tenant | Model::query()->noTenant()->fetchAll() |
| Omitir scope | Model::query()->withoutGlobalScope('name')->fetchAll() |
| Traducido | Model::query()->withTranslations()->fetchAll() |
| Depurar SQL | Model::query()->where(...)->toSql() |
| Obtener meta | Model::getData($id, 'key', 'default') |
| Establecer meta | Model::setData($id, 'key', 'value') |