v5 ya live

HASTA

60% DTO
Oferta Caliente de Diciembre
Launch Sale
0 DÍAS
:
0 HORAS
:
0 MINUTOS
:
0 SEGUNDOS
OBTENER AHORA percent icon

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.

Versión:
Categorías

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.

Visión general de la arquitectura

El ORM está construido alrededor de cuatro clases principales:

ClaseNamespaceRol
ModelBookneticApp\Providers\DB\ModelClase base para todos los modelos. Define tabla, relaciones, scopes y hooks del ciclo de vida.
QueryBuilderBookneticApp\Providers\DB\QueryBuilderQuery builder encadenable. Gestiona WHERE, JOIN, ORDER BY y operaciones similares de consulta.
CollectionBookneticApp\Providers\DB\CollectionEnvuelve una sola fila de base de datos. Proporciona acceso por propiedades, acceso tipo array y carga de relaciones.
DBBookneticApp\Providers\DB\DBWrapper 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)

Modelos

Los modelos representan tablas de base de datos y actúan como punto de entrada para la mayoría de las operaciones del ORM.

Definir un modelo

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.

Convención de nombres de tabla

Los nombres de tabla se generan automáticamente a partir del nombre de la clase usando esta regla:

PascalCase → snake_case + plural

Clase del modeloTabla generadaTabla completa en DB
Customercustomerswp_bkntc_customers
Appointmentappointmentswp_bkntc_appointments
ServiceExtraservice_extraswp_bkntc_service_extras
AppointmentPriceappointment_priceswp_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';
}

Modelos principales disponibles

Los siguientes son algunos de los modelos integrados más utilizados:

ModeloTablaMulti-TenantDescripción
AppointmentappointmentsReservas
AppointmentExtraappointment_extrasNoExtras asociados a citas
AppointmentPriceappointment_pricesNoDesglose de precios por cita
CustomercustomersRegistros de clientes
CustomerCategorycustomer_categoriesAgrupación de clientes
ServiceservicesServicios disponibles
ServiceCategoryservice_categoriesAgrupación de servicios
ServiceExtraservice_extrasExtras adicionales para servicios
ServiceStaffservice_staffsNoRelación servicio-personal
StaffstaffsMiembros del personal
LocationlocationsUbicaciones del negocio
HolidayholidaysDías no laborables
SpecialDayspecial_daysSobrescrituras especiales de horario
TimesheettimesheetsHorarios de trabajo
WorkflowworkflowsReglas de automatización
WorkflowLogworkflow_logsRegistros de ejecución de automatizaciones
AppearanceappearancesConfiguración del tema del panel de reservas
DatadataNoAlmacenamiento meta clave-valor
TranslationtranslationsNoContenido multiidioma

Query Builder

El query builder es la interfaz principal para recuperar, filtrar, unir, actualizar y eliminar registros.

Iniciar una consulta

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);

Recuperar datos

Obtener una sola fila

// 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;

Obtener múltiples filas

// 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;
}

Obtener como arrays simples

// Devuelve un array de arrays asociativos (sin envolver en Collection)
$rows = Customer::query()
    ->where('gender', 'male')
    ->fetchAllAsArray();

Obtener por ID

$appointment = Appointment::get(42);
// Equivalente a: Appointment::query()->where('id', 42)->fetch();

Condiciones WHERE

Where básico

// 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();

Where con array

// Pasar múltiples condiciones como array
Appointment::where([
    'status'   => 'approved',
    'staff_id' => 3
])->fetchAll();

OR Where

Customer::query()
    ->where('first_name', 'John')
    ->orWhere('first_name', 'Jane')
    ->fetchAll();

Where ID

// Atajo para where('id', $value)
Customer::whereId(5)->fetch();

Comprobaciones NULL

// 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();

Cláusula IN

// Pasar un array como valor
Appointment::where('status', ['approved', 'pending'])->fetchAll();

// NOT IN
Appointment::where('status', 'not in', ['canceled', 'rejected'])->fetchAll();

LIKE

// 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();

FIND_IN_SET

Para columnas con valores separados por comas:

Service::query()->whereFindInSet('staff_ids', 5)->fetchAll();
// Genera: WHERE FIND_IN_SET('5', staff_ids)

Condiciones agrupadas (Where anidado)

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'

Subconsulta en Where

// 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

// 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_count

Ordenación

Appointment::query()
    ->orderBy(['starts_at' => 'DESC'])
    ->fetchAll();

// Múltiples columnas de ordenación
Appointment::query()
    ->orderBy(['status' => 'ASC', 'starts_at' => 'DESC'])
    ->fetchAll();

Agrupación

Appointment::query()
    ->select(['status', 'COUNT(*) as count'])
    ->groupBy(['status'])
    ->fetchAll();

Paginación

$page = 2;
$perPage = 10;

$appointments = Appointment::query()
    ->orderBy(['starts_at' => 'DESC'])
    ->limit($perPage)
    ->offset(($page - 1) * $perPage)
    ->fetchAll();

Agregados

// 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');

Joins

Join básico

$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ámetroTipoDescripción
$joinTostringNombre de tabla o nombre de clase del modelo
$selectFieldsarray|stringColumnas a seleccionar de la tabla unida
$field1string|nullCampo de join de la tabla unida
$field2string|nullCampo de join de la tabla principal
$unselectOldFieldsboolSi es true, elimina la selección de la tabla principal
$aliasstring|nullAlias para la tabla unida

Join basado en relaciones

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ón

Self Join

Appointment::query()
    ->leftJoinSelf('parent', ['status'], 'id', 'recurring_id')
    ->fetchAll();
// Une la tabla appointments consigo misma usando el alias 'parent'

Join con alias

Appointment::query()
    ->leftJoin('staffs', ['name'], 'staff_id', 'id', false, 'assigned_staff')
    ->fetchAll();

// Acceso: $appointment->assigned_staff_name

Insertar

Customer::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

// 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']);

Actualizar con referencias raw a campos

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

// 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();

Depuración

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, 10

Collection

Cuando fetch() devuelve un único registro, se devuelve como un objeto Collection. Cuando se usa fetchAll(), devuelve un array de objetos Collection.

Acceder a los datos

$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();

Atributos calculados

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"

Seguridad frente a null

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;
}

Relaciones

Las relaciones permiten moverte entre modelos relacionados y también sirven para el mapeo automático en joins.

Definir relaciones

Las relaciones se definen en el array $relations del modelo:

public static $relations = [
    'relationName' => [RelatedModel::class, 'foreignKey', 'localKey']
];
ParámetroDescripción
RelatedModel::classLa clase del modelo relacionado
foreignKeyLa columna en la tabla relacionada
localKeyLa 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'],
];

Usar relaciones

Lazy loading

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;

Eager loading mediante join

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ón

Scopes globales

Los 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.

Cómo funcionan los scopes

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 MultiTenant
  • WHERE staff_id = {current_staff} para usuarios backend no administradores

El parámetro $queryType

El callback del scope recibe el tipo de consulta, por lo que puedes variar el comportamiento según la operación:

queryTypeCuándo
'select'Consultas SELECT como fetch, fetchAll, count y operaciones similares
'insert'Consultas INSERT
'update'Consultas UPDATE
'delete'Consultas DELETE

Desactivar un scope

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();

Multi-tenancy

El trait MultiTenant proporciona aislamiento automático por tenant en modo SaaS.

Cómo funciona

Cuando un modelo usa MultiTenant:

  • Las consultas SELECT / UPDATE / DELETE incluyen automáticamente WHERE tenant_id = {current_tenant}
  • Las consultas INSERT establecen automáticamente tenant_id = {current_tenant}
  • En modo no SaaS, el filtrado se hace con WHERE tenant_id IS NULL
class 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();

Omitir el filtro tenant

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();

Modelos sin multi-tenancy

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.

Hooks del ciclo de vida

Los modelos soportan hooks del ciclo de vida que se disparan alrededor de operaciones CRUD:

HookSe dispara cuando¿Puede cancelar?
onCreating($closure)Antes de INSERT
onCreated($closure)Después de INSERTNo
onUpdating($closure)Antes de UPDATE
onUpdated($closure)Después de UPDATENo
onDeleting($closure)Antes de DELETE
onDeleted($closure)Después de DELETENo
onRetrieving($closure)Antes de SELECTNo
onRetrieved($closure)Después de SELECT, por filaNo

Si un hook before como onCreating, onUpdating o onDeleting devuelve false, la operación se cancela.

Uso

// 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']);
});

Timestamps automáticos

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.

Propiedad automática

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;
}

Meta datos (almacenamiento clave-valor)

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.

En un modelo (estático)

// 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');

En una instancia Collection

$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:

ColumnaDescripción
table_nameTabla de origen, como customers
row_idID del registro
data_keyClave
data_valueValor

Clase DB (bajo nivel)

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.

Resolución de nombres de tabla

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"

Consultas raw

// 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);

Acceso directo a la base de datos

// Obtener la instancia $wpdb de WordPress
$wpdb = DB::DB();

// Usar cualquier método de $wpdb
$wpdb->query("...");
$wpdb->get_row("...");
$wpdb->get_results("...");

Caché de consultas

// 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();

Último ID insertado

Customer::query()->insert([...]);
$id = DB::lastInsertedId();
// o
$id = Customer::lastId();

Referencias a campos

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')
]);

Traducciones

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 existe

Patrones comunes

Estos son algunos patrones de uso comunes del ORM en el desarrollo de Booknetic.

Paginación con conteo total

$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();

Consulta compleja de reporte

$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;
}

Construcción condicional de consultas

$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();

Comprobar si un registro existe

$exists = Customer::query()
    ->where('email', '[email protected]')
    ->count() > 0;

Patrón obtener o crear

$customer = Customer::where('email', $email)->fetch();

if (!$customer) {
    Customer::query()->insert([
        'first_name' => $firstName,
        'last_name'  => $lastName,
        'email'      => $email
    ]);
    $customer = Customer::get(Customer::lastId());
}

Resumen

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ónCódigo
Obtener por IDModel::get($id)
Encontrar unoModel::where('field', 'value')->fetch()
Encontrar variosModel::where('field', 'value')->fetchAll()
CountModel::where('field', 'value')->count()
SumModel::where('field', 'value')->sum('column')
InsertarModel::query()->insert([...])
ActualizarModel::whereId($id)->update([...])
EliminarModel::whereId($id)->delete()
JoinModel::query()->leftJoin('table', ['cols'], 'fk', 'pk')->fetchAll()
SubQueryModel::query()->selectSubQuery($subQB, 'alias')->fetchAll()
Sin tenantModel::query()->noTenant()->fetchAll()
Omitir scopeModel::query()->withoutGlobalScope('name')->fetchAll()
TraducidoModel::query()->withTranslations()->fetchAll()
Depurar SQLModel::query()->where(...)->toSql()
Obtener metaModel::getData($id, 'key', 'default')
Establecer metaModel::setData($id, 'key', 'value')