Skip to content

API Reference ​

This document provides comprehensive documentation for the Record API package endpoints, request/response formats, and usage examples.

Record CRUD API Documentation ​

This section documents the record CRUD endpoints provided by this package, including request/response formats, filtering, pagination, and error handling.

API Docs Access Mode ​

The bundled docs UI endpoint is:

  • GET /api-docs

Use config/record.php to control visibility:

php
'api_docs' => [
    'is_private' => env('RECORD_API_DOCS_PRIVATE', false),
    'access_token_key' => 'access_token',
    'login_api' => '/v1/auth/login',
    'email' => env('RECORD_API_DOCS_EMAIL'),
],

Behavior:

  • If api_docs config is missing, docs stay public by default.
  • If is_private=false, /api-docs loads Scalar directly.
  • If is_private=true, /api-docs shows a custom login form first.
  • If is_private=true, Scalar uses secure web routes:
    • POST /api-docs/auth/login
    • POST /api-docs/auth/logout
    • GET /api-docs/openapi.json
  • In private mode, API endpoints /{api_prefix}/docs/openapi(.json) and /{api_prefix}/docs/llms.* are hidden with 404 to avoid schema leakage.
  • login_api supports relative route or absolute URL, so each client project can point docs login to its own auth endpoint.
  • access_token_key controls token extraction key from login response payload.
  • email is optional and enforces a fixed docs login account.

Query Filtering (applyRequestFilters macro) ​

The package extends Laravel's Illuminate\Database\Query\Builder with a macro applyRequestFilters. This is the same filtering/pagination mechanism used by the record CRUD endpoints when listing records.

php
use Illuminate\Support\Facades\DB;
use Illuminate\Http\Request;

public function index(Request $request)
{
    // Start with any base query
    $query = DB::table('invoices')->where('active', true);

    // Apply filters from request (e.g. ?status=eq.paid&sortby=created_at)
    $result = $query->applyRequestFilters($request);

    return response()->json($result);
}

The applyRequestFilters method returns an array containing:

  • data: The result set
  • meta: Pagination metadata
  • headers: Response headers
  • filters: Applied filters
  • request: Original request object

Builder macro signature:

php
public function applyRequestFilters(
    \Illuminate\Http\Request $request,
    bool $isArray = false,
    string $orderBy = 'id',
    ?string $tenantColumn = ''
): array

Because the macro has named parameters, you can also call it using named arguments (PHP 8+):

php
$result = DB::table('invoices')->applyRequestFilters(
    request: $request,
    isArray: true,
    orderBy: 'created_at',
    tenantColumn: 'company_id',
);

Relationship Selection & Filtering ​

Relationship loading uses the select query parameter for nested inclusion and filtering. The legacy with parameter is still supported for simple eager loading without column filtering.

Syntax:?select=column1,column2,relationship(column1,column2,filter)

Examples:

  1. Basic Inclusion:GET /api/v1/invoices?select=*,customer(*) Fetches all columns from invoices and all columns from the customer relationship.

  2. Nested Inclusion:GET /api/v1/customers?select=*,orders(*,items(*)) Fetches customers with their orders and order items.

  3. Filtering Nested Records (Embedding): You can apply filters to related records using the column=operator.value syntax inside the relationship parenthesis.

    GET /api/v1/projects?select=*,tasks(*,assignees(*,name=eq.admin))

    This fetches:

    • All columns from projects
    • All columns from tasks
    • All columns from assignees (users) WHERE name equals admin.

    Supported Operators in Nested Filters:

    • eq: Equal (name=eq.John)
    • neq: Not equal (status=neq.archived)
    • gt, gte: Greater than (or equal) (age=gte.18)
    • lt, lte: Less than (or equal) (price=lt.100)
    • like: Pattern matching (name=like.%Smith%)
    • in: In list (status=in.active,pending)

    Note: If no operator is specified (e.g., name=admin), it defaults to equality (eq).

  4. Filtering by Relationship (Top-Level): You can filter the main result set based on criteria in related tables using the dot notation relationship.column=operator.value.

    GET /api/v1/users?select=*,posts(*)&roles.name=eq.admin

    This fetches:

    • Users who have a role named 'admin'.
    • Includes their posts (if requested via select).

    Supported Relationships:

    • belongsTo
    • hasMany (uses EXISTS subquery)
    • hasManyThrough
    • belongsToMany (uses pivot table)

    Example:GET /api/v1/posts?author.name=eq.John Fetches posts where the author's name is 'John'.

  5. with Parameter (custom controllers only): The generated endpoints ignore with; embed relationships with select instead:

    • GET /api/v1/customers?select=*,invoices(*)
    • GET /api/v1/customers?select=*,invoices(*),contacts(*)?with= works only in your own controllers that use the QueryHelpers trait.

Supported Relationship Types ​

The dynamic API understands all relationship types declared in RecordRelationshipsEnum. These relationships are configured per table via the relationships array on RecordTableType and are available to select and filter expressions.

Belongs To (belongsTo) ​

Use RecordBelongsToType when the current table has a foreign key pointing to a parent table.

Example configuration:

php
use Mylekha\RecordApi\Enums\RecordRelationshipsEnum;
use Mylekha\RecordApi\Types\RecordBelongsToType;

'invoices' => new RecordTableType(
    table: 'invoices',
    relationships: [
        'customer' => new RecordBelongsToType(
            table: 'customers',
            type: RecordRelationshipsEnum::BELONGS_TO,
            foreignKey: 'customer_id',
            ownerKey: 'id',
        ),
    ],
),

Example usage:

  • GET /api/v1/invoices?select=*,customer(*)
  • GET /api/v1/invoices?customer.name=like.%Acme%

Has Many (hasMany) ​

Use RecordHasManyType when the current table is the parent and the related table has the foreign key.

Example configuration:

php
use Mylekha\RecordApi\Enums\RecordRelationshipsEnum;
use Mylekha\RecordApi\Types\RecordHasManyType;

'customers' => new RecordTableType(
    table: 'customers',
    relationships: [
        'invoices' => new RecordHasManyType(
            table: 'invoices',
            type: RecordRelationshipsEnum::HAS_MANY,
            foreignKey: 'customer_id',
            localKey: 'id',
        ),
    ],
),

Example usage:

  • GET /api/v1/customers?select=*,invoices(*)
  • GET /api/v1/customers?invoices.status=eq.paid

Has One (hasOne) ​

Use RecordHasManyType with RecordRelationshipsEnum::HAS_ONE when the related table has a unique row per parent (semantically has-one, loaded via the same optimized path as has-many).

Example configuration:

php
'users' => new RecordTableType(
    table: 'users',
    relationships: [
        'profile' => new RecordHasManyType(
            table: 'user_profiles',
            type: RecordRelationshipsEnum::HAS_ONE,
            foreignKey: 'user_id',
            localKey: 'id',
        ),
    ],
),

Example usage:

  • GET /api/v1/users?select=*,profile(*)

Belongs To Many (belongsToMany) ​

Use RecordMetaBelongsToManyType for many-to-many relationships backed by a pivot table. This cannot be replaced by RecordAassociationType because RecordAassociationType only supports has-many-through over a meta table with owner/target columns and does not support pivot semantics (extra pivot columns, timestamps, morph pivots, or arbitrary pivot keys).

Example configuration:

php
use Mylekha\RecordApi\Types\RecordMetaBelongsToManyType;

'users' => new RecordTableType(
    table: 'users',
    relationships: [
        'roles' => new RecordMetaBelongsToManyType(
            related: 'roles',
            type: RecordRelationshipsEnum::BELONGS_TO_MANY,
            table: 'role_user',
            foreignPivotKey: 'user_id',
            relatedPivotKey: 'role_id',
        ),
    ],
),

Example usage:

  • GET /api/v1/users?select=*,roles(*)
  • GET /api/v1/users?roles.name=eq.admin

Has Many Through (hasManyThrough) ​

Three variants are supported:

  1. Standard has-many-through using RecordHasManyThroughType.
  2. Global meta-table has-many-through using RecordMetaHasManyThroughType.
  3. Association has-many-through using RecordAassociationType with simplified parameters.

RecordAassociationType can replace RecordMetaHasManyThroughType only when your meta table uses the standard columns (owner, owner_id, target, target_id) and owner stores the source table name while target stores the related table name. If your meta table uses different column names or needs ownerColumn customization, keep RecordMetaHasManyThroughType.

Standard example:

php
use Mylekha\RecordApi\Types\RecordHasManyThroughType;

'projects' => new RecordTableType(
    table: 'projects',
    relationships: [
        'tasks' => new RecordHasManyThroughType(
            table: 'tasks',
            through: 'project_tasks',
            firstKey: 'project_id',
            secondKey: 'id',
            localKey: 'id',
            secondLocalKey: 'task_id',
        ),
    ],
),

Global meta-table example:

php
use Mylekha\RecordApi\Types\RecordMetaHasManyThroughType;

'packages' => new RecordTableType(
    table: 'packages',
    relationships: [
        'modules' => new RecordMetaHasManyThroughType(
            table: 'modules',
            through: 'meta',
            firstKey: 'owner_id',
            secondKey: 'id',
            localKey: 'id',
            secondLocalKey: 'target_id',
            ownerColumn: 'owner',
            owner: 'package',
        ),
    ],
),

Example usage:

  • GET /api/v1/projects?select=*,tasks(*)
  • GET /api/v1/packages?select=*,modules(*)

Association example (simplified parameters with meta table):

php
use Mylekha\RecordApi\Types\RecordAassociationType;

'packages' => new RecordTableType(
    table: 'packages',
    relationships: [
        'modules' => new RecordAassociationType(
            related: 'meta',
            type: RecordRelationshipsEnum::HAS_MANY_THROUGH,
            fromObjectType: 'packages',
            fromObjectId: 'owner_id',
            toObjectType: 'modules',
            toObjectId: 'target_id',
        ),
    ],
),

Example usage:

  • GET /api/v1/packages?select=*,modules(*)

Has One Through (hasOneThrough) ​

RecordHasManyThroughType also supports the HAS_ONE_THROUGH semantic. In most cases, you configure it the same way as has-many-through but use the enum to indicate the expected cardinality.

Example configuration:

php
'users' => new RecordTableType(
    table: 'users',
    relationships: [
        'latestInvoice' => new RecordHasManyThroughType(
            table: 'invoices',
            through: 'invoice_logs',
            firstKey: 'user_id',
            secondKey: 'id',
            localKey: 'id',
            secondLocalKey: 'invoice_id',
            orderBy: ['created_at' => 'desc'],
            type: RecordRelationshipsEnum::HAS_ONE_THROUGH,
        ),
    ],
),

Example usage:

  • GET /api/v1/users?select=*,latestInvoice(*)

Morph Relationships ​

Morph relationships are detected via RecordRelationshipsEnum::isMorphRelationship() and are supported anywhere relationship selection is supported.

morphTo / morphOne / morphMany ​

These are typically configured via specialized resource classes or custom loaders. The enum types are:

  • MORPH_TO
  • MORPH_ONE
  • MORPH_MANY

Example conceptual usage (comments only):

  • A comments table with commentable_type and commentable_id can be exposed as a morphTo relationship from comments to multiple parent tables (e.g. posts, invoices).
  • In the API, you can select nested comments using ?select=*,comments(*) regardless of the underlying parent model.
morphToMany / morphByMany ​

Many-to-many morph relationships use a pivot table and are treated as pivot-supporting morph types.

Example using RecordMetaBelongsToManyType with a morph relation:

php
'models' => new RecordTableType(
    table: 'models',
    relationships: [
        'roles' => new RecordMetaBelongsToManyType(
            related: config('permission.models.role'),
            type: RecordRelationshipsEnum::MORPH_TO_MANY,
            table: config('permission.table_names.model_has_roles'),
            foreignPivotKey: config('permission.column_names.model_morph_key'),
            relatedPivotKey: 'role_id',
            relation: 'model',
        ),
    ],
),

Example usage:

  • GET /api/v1/models?select=*,roles(*)
Spatie Permission (spatiePermission) ​

The package includes a dedicated RecordSpatiePermissionType to integrate with spatie/laravel-permission using a morphToMany pattern.

Example configuration:

php
use Mylekha\RecordApi\Types\RecordSpatiePermissionType;

'users' => new RecordTableType(
    table: 'users',
    relationships: [
        'roles' => new RecordSpatiePermissionType(
            related: config('permission.models.role'),
            relation: 'model',
            recordRelationshipsEnum: RecordRelationshipsEnum::SPATIE_PERMISSION,
            table: config('permission.table_names.model_has_roles'),
            foreignPivotKey: config('permission.column_names.model_morph_key'),
            relatedPivotKey: 'role_id',
        ),
    ],
),

Example usage:

  • GET /api/v1/users?select=*,roles(*)
  • GET /api/v1/users?roles.name=eq.admin

Relationship Write Payload Guide ​

For POST / PUT / PATCH, relationship input is type-driven and should follow the config in RecordTableType->relationships.

What can be sent in payload ​

Enum type (RecordRelationshipsEnum)Payload supportPayload shape
BELONGS_TO✅ FK scalar onlycustomer_id: 10
HAS_MANY✅ alias arrayitems: [1, {"id": 2}, {"name": "Line A"}]
BELONGS_TO_MANY✅ alias arrayroles: [1, {"id": 2}]
HAS_MANY_THROUGH✅ alias arraytasks: [3, {"id": 4}]
MORPH_MANY✅ alias arraycomments: [1, {"id": 2}]
MORPH_TO_MANY✅ alias arrayroles: [1, {"id": 2}]
MORPH_BY_MANY✅ alias arraytags: [1, {"id": 2}]
SPATIE_PERMISSION✅ alias arrayroles: [1, {"id": 2}]
HAS_ONE⚠️ use FK style of your schemaPrefer scalar FK field in root payload
HAS_ONE_THROUGH⚠️ not a direct write aliasUse main table fields / custom function
MORPH_TO⚠️ use morph columns in root payloadcommentable_type, commentable_id
MORPH_ONE⚠️ use FK style of your schemaPrefer scalar FK field in root payload

FK-style examples (BELONGS_TO) ​

json
{
  "ref_number": "INV-1001",
  "customer_id": 10
}

Do not send:

json
{
  "customer": { "id": 10, "name": "Acme" }
}

Many-type alias examples (*Many) ​

json
{
  "items": [
    1,
    { "id": 2 },
    { "name": "Line A", "qty": 1 },
    { "id": 5, "_delete": true }
  ]
}

Pivot-style examples (BELONGS_TO_MANY, MORPH_TO_MANY, SPATIE_PERMISSION) ​

json
{
  "roles": [
    1,
    { "id": 2 },
    { "id": 3, "_delete": true }
  ]
}

Notes ​

  • Array relationship aliases are accepted only when declared in table relationships config.
  • For BELONGS_TO, the payload should use root FK scalar fields, not nested objects.
  • _delete / _destroy can be used on alias-array items where relationship handling supports detach/remove.

Base Configuration ​

API Prefix ​

All endpoints are served under a configurable prefix defined in config/record.php:

php
'api_prefix' => 'api/v1',
'rpc_prefix' => 'rpc',

Default:

  • CRUD API: /api/v1
  • Global RPC: /api/v1/rpc

Examples: /api, /api/v1, /api/v2

Global RPC ​

Global functions can be executed via the configured RPC prefix. Nested function names are supported.

POST /api/v1/rpc/{functionName}

Example:

  • POST /api/v1/rpc/auth/login
  • POST /api/v1/rpc/system/status

Table Configuration Files ​

Record behavior is driven by RecordTableType configurations defined in config/record.php and (optionally) in per-table files under config/records/tables.

  • config/record.php contains global options and can inline smaller schemas.
  • config/records/tables/{name}.php can return a single RecordTableType or an array of [table_name => RecordTableType] for large schemas.

You can scaffold a new per-table configuration file via Artisan:

bash
php artisan record-api:record customers

This creates config/records/tables/customers.php with a basic RecordTableType definition for the customers table. After creating the file and the corresponding database table, you can populate the columns metadata from the database schema:

bash
php artisan record-api:sync-record-columns --force

Record endpoints use table-level access rules from config/record.php:

  • If a table/action is configured as public (RecordTablePublic), the endpoint is accessible without authentication.
  • Otherwise, the controller requires an authenticated user from the guard configured in config/record-api.php (record-api.auth.guard, default: api) and checks permissions.
  • Permission checks support a custom authorization handler via record.authorization.

Custom authorization handler (record.authorization) options:

  • null (default): use Gate::forUser($user)->allows($permission)
  • class-string: resolved from container and called as handle($user, $permission, $table, $action): bool
  • closure/callable: called as fn($user, string $permission, string $table, string $action): bool

Example:

php
// config/record.php
'authorization' => \App\Security\RecordAuthorization::class,
php
<?php

namespace App\Security;

final class RecordAuthorization
{
    public function handle(mixed $user, string $permission, string $table, string $action): bool
    {
        return \Illuminate\Support\Facades\Gate::forUser($user)->allows($permission);
    }
}

Middleware Stack ​

  • api - API middleware group
  • request.id - Request ID tracking for audit trails
  • Rate limiting with different throttles for different operation types
  • record.route.middleware:{action} - Dynamic middleware dispatcher resolved from config/record.php middleware_map

Middleware Map (Public / Auth / Auth+Subscription) ​

Use middleware_map in config/record.php to apply middleware by endpoint group/action and per table.

php
'middleware_map' => [
    'default' => [
        '*' => [],
        'read' => [],
        'write' => ['auth:sanctum'],
        'function' => ['auth:sanctum'],
    ],
    'tables' => [
        'customers' => [
            'read' => [],
        ],
        'orders' => [
            'write' => ['auth:sanctum', 'subscribed'],
            'table_function' => ['auth:sanctum', 'subscribed'],
        ],
    ],
],

How this matches common client requirements:

  • Public query route: keep read empty (or only safe middleware like throttling).
  • Auth-only route: use write => ['auth:sanctum'] or per-action create, update, delete.
  • Auth + subscription route: add subscribed in table/action stack (e.g. orders.write).

Request Context in Hooks and Custom Audit ​

Request context is available to hooks and custom audit callback via:

  • $context['request_context'] in trigger/audit callback params
  • request()->attributes->get('record_context')

Built-in request_context payload:

  • tenant_id (string|int|null)
  • tenant_column (string, usually tenant_id)
  • tenant_source (attribute|header|null)
  • user (array|null) with keys:
    • id (mixed)
    • guard (string)
  • request_id (string|null)
  • table (string)
  • action (string|null)

Source priority behavior (built-in):

  • attribute: recommended for trusted middleware-populated tenant (resolved_tenant_id) or context tenant (record_context.tenant_id)
  • header: fallback to tenant header (X-Tenant-ID by default)

This same priority is also used by Eloquent trait filtering (QueryHelpersTrait::scopeApplyRequestFilters), so model queries remain aligned with dynamic CRUD tenant behavior.

Tenant filtering in QueryHelpersTrait is applied when tenant mode is enabled and tenant column exists by any of:

  • model fillable
  • registered RecordTableType columns
  • database schema column check

Example middleware to set trusted tenant (resolved_tenant_id) and enrich record_context:

php
<?php

declare(strict_types=1);

namespace App\Http\Middleware;

use Closure;
use Illuminate\Http\Request;
use Symfony\Component\HttpFoundation\Response;

final class ResolveTenantContext
{
    public function handle(Request $request, Closure $next): Response
    {
        $tenantId = $request->user()?->tenant_id;

        if ($tenantId !== null && $tenantId !== '') {
            $request->attributes->set('resolved_tenant_id', $tenantId);
        }

        $context = $request->attributes->get('record_context', []);
        if (!is_array($context)) {
            $context = [];
        }

        $context['tenant_id'] = $tenantId;
        $context['tenant_source'] = 'attribute';
        $context['client_app'] = 'backoffice';
        $request->attributes->set('record_context', $context);

        return $next($request);
    }
}

Then register middleware in record.middleware_map for table/action route groups so CRUD/trigger flow can consume the context.

Example trigger (beforeCreate) using context for auto value:

php
public static function beforeCreate(\Illuminate\Http\Request $request, string $table, array $context): array
{
    $ctx = $context['request_context'] ?? $request->attributes->get('record_context', []);
    $tenantId = $ctx['tenant_id'] ?? null;
    $userId = $ctx['user']['id'] ?? null;

    $payload = $request->all();
    $payload['tenant_id'] = $tenantId;
    $payload['created_by'] = $userId;
    $request->replace($payload);

    return [$request, $table, $context];
}

Table-Level Validation ​

Each table configured in config/record.php (or in per-table files under config/record/tables) can define event-specific validators using the RecordTableType configuration. Validators support:

  • Callable arrays (e.g. [ClassName::class, 'method'])
  • RecordValidationType objects
  • Arrays of validator configs (multiple validators per event)
php
use App\Record\Validators\RecordValidator;
use Mylekha\RecordApi\Types\RecordTableType;
use Mylekha\RecordApi\Types\RecordValidationType;

return [
    'invoices' => new RecordTableType(
        pmsName: 'invoice',
        createValidator: [
            [RecordValidator::class, 'createInvoice'],
            new RecordValidationType(
                class: RecordValidator::class,
                functionName: 'createInvoice',
            ),
        ],
        updateValidator: [
            new RecordValidationType(
                class: RecordValidator::class,
                functionName: 'updateInvoice',
            ),
        ],
        deleteValidator: [
            [RecordValidator::class, 'deleteInvoice'],
        ],
    ),
];

If you run php artisan config:cache, avoid closures (including fn (...) => ...) in config files. Use callable arrays like [ClassName::class, 'method'] (or a 'ClassName::method' callable string).

Example validator class:

php
<?php

declare(strict_types=1);

namespace App\Record\Validators;

use Illuminate\Contracts\Validation\Validator as ValidatorContract;
use Illuminate\Http\Request;
use Illuminate\Support\Facades\Validator;

final class RecordValidator
{
    public static function createInvoice(Request $request, ?int $id = null): ValidatorContract
    {
        return Validator::make($request->all(), [
            'invoice_number' => 'required|string|max:50',
            'customer_id' => 'required|integer',
            'total' => 'required|numeric|min:0',
        ]);
    }

    public static function updateInvoice(Request $request, ?int $id = null): ValidatorContract
    {
        return Validator::make($request->all(), [
            'status' => 'sometimes|required|in:draft,pending,paid,cancelled',
        ]);
    }

    public static function deleteInvoice(Request $request, ?int $id = null): ValidatorContract
    {
        return Validator::make(['id' => $id], [
            'id' => 'required|integer',
        ]);
    }

    public static function deletePackage(Request $request, ?int $id = null): ValidatorContract
    {
        return Validator::make(['id' => $id], [
            'id' => 'required|integer',
        ]);
    }
}
  • createValidator runs before POST /{api_prefix}/{table}.
  • updateValidator runs before PUT/PATCH /{api_prefix}/{table}/{id}.
  • deleteValidator runs before DELETE /{api_prefix}/{table}/{id}.

If a validator fails, the API returns a 422 Validation Error with the standard error format described in the Error Responses section.

Table-Level Triggers ​

In addition to validators, you can configure lifecycle triggers per table using RecordTableTriggerType. Triggers allow you to run custom code before and after core CRUD operations.

php
use Illuminate\Http\Request;
use Mylekha\RecordApi\Types\RecordTableType;
use Mylekha\RecordApi\Types\RecordTablePublic;
use Mylekha\RecordApi\Types\RecordTableTriggerType;

return [
    'users' => new RecordTableType(
        pmsName: 'user',
        public: new RecordTablePublic(
            read: false,
            write: false,
        ),
        beforeRead: [
          [
            new RecordTableTriggerType(
            class: \App\Record\Triggers\UserTriggers::class,
            functionName: 'beforeRead',
          )
          ]
        ],
        afterRead: [
          [
            new RecordTableTriggerType(
            class: \App\Record\Triggers\UserTriggers::class,
            functionName: 'afterRead',
          )
          ]
        ],
        beforeCreate: [
          [
            new RecordTableTriggerType(
            class: \App\Record\Triggers\UserTriggers::class,
            functionName: 'beforeCreate',
          )
          ]
        ],
        afterCreate: [
          [
            new RecordTableTriggerType(
            class: \App\Record\Triggers\UserTriggers::class,
            functionName: 'afterCreate',
          )
          ]
        ],
        beforeUpdate: [
          [
            new RecordTableTriggerType(
            class: \App\Record\Triggers\UserTriggers::class,
            functionName: 'beforeUpdate',
          )
          ]
        ],
        afterUpdate: [
          [
            new RecordTableTriggerType(
              class: \App\Record\Triggers\UserTriggers::class,
              functionName: 'afterUpdate',
          )
          ]
        ],
        beforeDelete: [
          [
            new RecordTableTriggerType(
              class: \App\Record\Triggers\UserTriggers::class,
              functionName: 'beforeDelete',
          )
          ]
        ],
        afterDelete: [
          [
            new RecordTableTriggerType(
              class: \App\Record\Triggers\UserTriggers::class,
              functionName: 'afterDelete',
          )
          ]
        ],
    ),
];

Global Triggers ​

You can register global triggers that apply to all tables and run in addition to per-table triggers.

php
use Mylekha\RecordApi\Types\RecordTableTriggerType;

return [
    'global_triggers' => [
        'beforeRead' => new RecordTableTriggerType(
            class: \App\Record\Triggers\GlobalTriggers::class,
            functionName: 'beforeRead'
        ),
        'afterRead' => new RecordTableTriggerType(
            class: \App\Record\Triggers\GlobalTriggers::class,
            functionName: 'afterRead'
        ),
        'beforeCreate' => new RecordTableTriggerType(
            class: \App\Record\Triggers\GlobalTriggers::class,
            functionName: 'beforeCreate'
        ),
        'afterCreate' => new RecordTableTriggerType(
            class: \App\Record\Triggers\GlobalTriggers::class,
            functionName: 'afterCreate'
        ),
        'beforeUpdate' => new RecordTableTriggerType(
            class: \App\Record\Triggers\GlobalTriggers::class,
            functionName: 'beforeUpdate'
        ),
        'afterUpdate' => new RecordTableTriggerType(
            class: \App\Record\Triggers\GlobalTriggers::class,
            functionName: 'afterUpdate'
        ),
        'beforeDelete' => new RecordTableTriggerType(
            class: \App\Record\Triggers\GlobalTriggers::class,
            functionName: 'beforeDelete'
        ),
        'afterDelete' => new RecordTableTriggerType(
            class: \App\Record\Triggers\GlobalTriggers::class,
            functionName: 'afterDelete'
        ),
    ],
];

Order:

  • before* global triggers run before table-level before* triggers.
  • after* global triggers run after table-level after* triggers.

Default Validation (Schema-Based) ​

You can enable automatic validation rules derived from table columns. This is optional and disabled by default.

php
return [
    'default_validation' => [
        'enabled' => true,
        'only_when_missing' => true,
        'required' => true,
        'types' => true,
        'unique' => true,
        'foreign_keys' => true,
    ],
];

Rules generated:

  • required: non-nullable columns without defaults (excluding system columns and tenant column)
  • types: basic mapping (uuid, integer, numeric, boolean, date, string, array)
  • unique: single-column unique indexes
  • foreign_keys: exists:{table},{column} based on DB constraints

Notes:

  • Only applies on create and update endpoints (including bulk create/update).
  • When only_when_missing is true, table validators still take priority.

Each trigger method is called with the following signature:

php
public static function someTrigger(Request $request, string $table, mixed ...$args): Request|array|null

Triggers are invoked with call_user_func_array([$class, $method], $params) where $params always starts with:

  • Request $request
  • string $table

Additional arguments depend on the event/endpoint. Common patterns:

  • beforeRead on list: [$request, $table, ['type' => 'index', 'tenant_id' => mixed]]
  • afterRead on list: [$request, $table, ['type' => 'index', 'filters' => [...], 'data' => [...], 'meta' => [...], 'tenant_id' => mixed, 'response' => JsonResponse]]
  • beforeCreate (single): [$request, $table, ['tenant_id' => mixed]]
  • beforeUpdate (single): [$request, $table, ['id' => mixed, 'tenant_id' => mixed]]
  • beforeDelete (single): [$request, $table, ['id' => mixed, 'tenant_id' => mixed, 'record' => ?object]]
  • afterDelete (single): [$request, $table, ['id' => mixed, 'tenant_id' => mixed, 'record' => ?object, 'soft_deleted' => bool, 'response' => JsonResponse]]
  • beforeRestore (single): [$request, $table, ['id' => mixed, 'tenant_id' => mixed, 'record' => ?object]]
  • afterRestore (single): [$request, $table, ['id' => mixed, 'tenant_id' => mixed, 'record' => ?object, 'restored' => int, 'response' => JsonResponse]]
  • Bulk endpoints may pass different params per item (e.g. [$request, $table, $item], [$request, $table, $id, $item], [$request, $table, $id])

Return values:

  • Return a Request to replace the current request (used mainly by beforeRead/beforeCreate/beforeUpdate/beforeDelete)
  • Return an array to merge into the request input (merged into $request->merge($result))
  • Return null / no return to leave the request unchanged

Trigger handlers are strict: invalid trigger config, missing class/method, or any runtime error will abort the request and surface as an API error response. Use validators when you want user-friendly 422 validation errors.

RecordTableType Parameters ​

RecordTableType configures a table’s routing, access control, caching, tenancy, validation, and lifecycle hooks.

Constructor (named arguments recommended):

php
new RecordTableType(
    pmsName: 'invoice',
    table: null,
    hasTenantId: false,
    softDeletes: false,
    disableAuditLog: false,
    disableCache: false,
    canRead: true,
    canCreate: true,
    canUpdate: true,
    canDelete: true,
    isAuthRead: true,
    isAuthWrite: true,
    public: new RecordTablePublic(),
    relationships: [],
    functions: [],
    primaryKey: 'id',
    columns: [],
    columnHiddens: [],
    columnWriteDisabled: [],
    columnIndexes: [],
    uniqueIds: [],
    auditLogFn: null,
    createValidator: null,
    updateValidator: null,
    deleteValidator: null,
    beforeRead: null,
    afterRead: null,
    beforeCreate: null,
    afterCreate: null,
    beforeUpdate: null,
    afterUpdate: null,
    beforeDelete: null,
    afterDelete: null,
);

Identity & Routing ​

  • pmsName (?string, default: null): Used for permission mapping (e.g. view:{pmsName}). If null, it falls back to the table name (singular, snake_case) for permission generation.
  • table (?string, default: null): Physical database table name. When null, the route table name is used as the DB table name.
  • primaryKey (?string, default: 'id'): Primary key column name used by show/update/delete endpoints.

Tenancy ​

  • hasTenantId (bool, default: false): Marks this table as tenant-scoped when record.enable_tenant_id is enabled. When enabled and hasTenantId is true, requests must include the tenant header (default: X-Tenant-ID) and queries are automatically filtered by tenant.

Access Control & Endpoint Availability ​

  • isAuthRead (bool, default: true): Auth requirement flag for read endpoints. true forces authentication, false makes read endpoints public.
  • isAuthWrite (bool, default: true): Auth requirement flag for write endpoints. true forces authentication, false makes write endpoints public.
  • public (RecordTablePublic|bool, legacy compatibility): Derived from isAuthRead/isAuthWrite for backward compatibility. New config should prefer auth flags directly.
  • canRead (bool, default: true): Enables/disables read endpoints for this table (list/show). When false, read routes respond as “not found”.
  • canCreate (bool, default: true): Enables/disables create endpoint.
  • canUpdate (bool, default: true): Enables/disables update and restore endpoints.
  • canDelete (bool, default: true): Enables/disables delete and force-delete endpoints.

Soft Deletes ​

  • softDeletes (bool, default: false): When true, list endpoints exclude deleted_at rows by default and restore/force-delete endpoints become relevant.

Caching & Audit ​

  • disableCache (bool, default: false): Disables query caching for this table (even if record.cache.enabled is true).
  • disableAuditLog (bool, default: false): Disables audit log inserts for create/update/delete on this table.
  • auditLogFn (?string, default: null): Reserved for custom audit log behavior; not used by the current runtime.

Schema & Search Metadata ​

  • columns (?array, default: []): Column metadata map. In normal usage this is populated at runtime from the database schema; leaving it empty is expected. It is used to whitelist payload fields and to detect audit columns like created_by, created_by_id, updated_by, last_updated_by, and last_updated_by_id.
  • columnHiddens (?array, default: []): List of column names to always hide from API responses. This is applied recursively to nested relationships as well. Hidden columns are removed even if their value is null.
  • columnWriteDisabled (?array, default: []): List of column names that are not writable via API payloads (create, update, upsert, and nested relationship writes). These columns are stripped from incoming payloads even if provided by the client.
  • columnIndexes (?array, default: []): Declares full-text index column sets for search optimization. Format: a list of column name arrays, e.g. [['name', 'description'], ['content']].
  • uniqueIds (array, default: []): Columns whose value the server generates, e.g. ['uuid'] for an immutable UUID secondary key next to the integer primary key. Every insert the engine performs into the table sets each listed column to a fresh lowercase UUIDv7 (Str::uuid7()), overriding any client-supplied value: create, bulk create, the create items of POST /{table}/bulk, upserts that insert a new row, and nested relationship creates into this table (has-many children, many-to-many related rows and pivot rows, has-many-through targets and through rows). Updates never change it: the columns are stripped from update payloads (single, bulk and nested) and from the update columns of upserts, so an upsert that matches an existing row keeps its value. Default validation adds no rules for these columns, and an upsert whose match_on names one is refused with 422. List the column in columns too (or let it be read from the database) to return it in responses. Tables without uniqueIds behave as before.

Relationships & Table RPC Functions ​

  • relationships (?array, default: []): Map of relationship name => relationship config object (e.g. RecordHasManyType, RecordBelongsToType, RecordMetaBelongsToManyType, etc.). Used by select relationship includes and nested relationship selections.
  • functions (?array, default: []): Map of function route name => function config (RecordFunctionType or array config). These are exposed under the table RPC route (e.g. /{api_prefix}/{table}/rpc/{function}) and can enforce permissions via pmsName. Use disableCache and cacheTTL to control function caching.

Validators ​

  • createValidator (callable|null, default: null): Runs before create. Must return an Illuminate\Contracts\Validation\Validator.
  • updateValidator (callable|null, default: null): Runs before update. Must return an Illuminate\Contracts\Validation\Validator.
  • deleteValidator (callable|null, default: null): Runs before delete. Must return an Illuminate\Contracts\Validation\Validator.

Validator signature:

php
fn(\Illuminate\Http\Request $request, ?int $id = null): \Illuminate\Contracts\Validation\Validator

Computed Attributes ​

  • attributes (?array, default: null): Map of computed field name => callable resolver. Resolvers are lazy — they only execute when the field name is explicitly listed in the ?select= query parameter. When no ?select= is provided, or the field is not in the requested columns, the resolver is never called.

Supported resolver formats:

FormatExample
Closurefn($row, $table) => value
[Class, method] array[BrandAttribute::class, 'getLogoUrl']
'Class@method' stringBrandAttribute::class . '@getLogoUrl'
'ClassName' stringBrandAttribute::class → calls handle($row, $table)

The resolver class is resolved via the Laravel container (app()), so constructor injection works. $row is the raw DB row (stdClass in most cases).

Config example:

php
use Mylekha\RecordApi\Types\RecordTableType;

'brands' => new RecordTableType(
    table: 'brands',
    attributes: [
        'full_label' => [\App\Attributes\BrandAttribute::class, 'getFullLabel'],
        'logo_url'   => \App\Attributes\BrandAttribute::class . '@getLogoUrl',
        'is_premium' => fn($row, $table) => ($row->tier ?? null) === 'premium',
    ],
),

Attribute class example:

php
namespace App\Attributes;

class BrandAttribute
{
    public function getFullLabel(mixed $row, string $table): string
    {
        return ($row->name ?? '') . ' (' . ($row->code ?? '') . ')';
    }

    public function getLogoUrl(mixed $row, string $table): ?string
    {
        $path = $row->logo_path ?? null;
        return $path ? config('app.url') . '/storage/' . $path : null;
    }
}

Requesting computed attributes via ?select=:

GET /api/v1/brands                               → no resolvers called
GET /api/v1/brands?select=id,name                → no resolvers called
GET /api/v1/brands?select=id,name,full_label     → only full_label resolver fires
GET /api/v1/brands?select=id,full_label,logo_url → both resolvers fire
GET /api/v1/brands/1?select=id,logo_url          → logo_url fires on single-record endpoint
GET /api/v1/brands?select=*,logo_url             → logo_url fires; * fetches all DB columns

Note: Attribute keys are not database columns. They are automatically excluded from the SQL SELECT to prevent "Unknown column" errors, while still being resolved and injected into the response after the query.

Column Casts ​

RecordTableType accepts a top-level casting property — a [column => cast] map that mirrors Laravel's $casts on Eloquent models. Casting is opt-in — only columns listed in casting are transformed; all others pass through unchanged. null values are always preserved as-is. Columns that use compositeFields are automatically skipped.

Flat keys target main-table columns. Dot-notation keys target columns inside eagerly-loaded relationships — 'relation.column' casts column on every row of that relation, regardless of whether the relation is a single object (belongsTo/hasOne) or a collection (hasMany/hasManyThrough).

Supported built-in cast strings (Laravel-compatible names):

CastPHP transformation
int / integer(int) $value
float / double / real(float) $value
decimal(float) $value
decimal:Nnumber_format((float) $value, N, '.', '')
string(string) $value
bool / boolean(bool) $value
array / jsonjson_decode($value, true)
objectjson_decode($value)
dateCarbon::parse($value)->toDateString()
datetimeCarbon::parse($value)->toISOString()
timestampCarbon::parse($value)->getTimestamp()

Custom cast forms — the callable receives ($value, $column, $row):

FormatExample
Closurefn($v, $col, $row) => strtoupper($v)
[Class, 'method'] array[GlobalCasting::class, 'bool'] — static or instance
'Class@method' string'App\\Casts\\MoneyCast@get'
'ClassName' stringMoneyCast::class → calls ->get($value, $column, $row)

Static methods are preferred automatically — the implementation checks is_callable([ClassName, method]) first before falling back to container instantiation.

Config example:

php
use App\Record\Casts\GlobalCasting;

// config/record.php
'casting' => [
    'is_active' => 'bool',
    'amount' => 'decimal:2',
],

// table config
'brands' => new RecordTableType(
    table: 'brands',
    columns: [
        'is_active' => ['type' => 'boolean'],
        'quantity' => ['type' => 'bigint'],
        'price' => ['type' => 'decimal(12,2)'],
        'name' => ['type' => 'varchar', 'nullable' => false],
    ],
    casting: [
        // explicit override (custom output format)
        'price'      => fn($v) => number_format((float) $v, 2, '.', ''),
        // overrides global record.casting['amount'] when table-level is defined
        'amount'     => 'string',
        // explicit override from inferred integer
        'quantity'   => 'string',
        // explicit override from inferred boolean
        'is_active'  => 'bool',
        'metadata'   => 'array',
        'score'      => 'decimal:4',
        'created_at' => 'datetime',
        // custom static method
        'is_cloud'   => [GlobalCasting::class, 'bool'],
        // inline Closure
        'status'     => fn($v) => strtoupper($v),
    ],
),

Custom cast class example (static methods work; no interface required):

php
namespace App\Record\Casts;

class GlobalCasting
{
    public static function bool(mixed $value, string $column, mixed $row): bool
    {
        return (bool) $value;
    }
}

Note: Columns that also define compositeFields are skipped by cast processing — composite type conversion takes precedence.

Relationship (dot-notation) casting example:

php
'orders' => new RecordTableType(
    table: 'orders',
    casting: [
        // flat main-table casts
        'total'       => 'float',
        'placed_at'   => 'datetime',

        // hasMany — cast each item row
        'items.price'    => 'float',
        'items.qty'      => 'int',
        'items.metadata' => 'array',

        // belongsTo — cast the single related object
        'customer.is_verified' => 'bool',
        'customer.score'       => 'decimal:2',

        // Closure on a relation column
        'customer.tier' => fn($v) => strtoupper($v),
    ],
),

The relation key (items, customer) must match the property name returned in the JSON response — i.e. the key used in with() / $appends. Nesting deeper than one level (e.g. 'order.items.price') is not supported; handle deeper nesting with a Closure on the intermediate relation.

Per-Action Permission Map ​

  • permissions (?array, default: null): Per-table map that overrides the auto-generated pmsName-based permissions for specific actions. Only the actions listed in this map are affected — all other actions still fall back to the standard PermissionUtils::mapPermissions() logic using pmsName and permission_separator.

Supported action keys:

KeyApplied by
'read'listRecords (GET list) and getRecordById (GET single)
'create'createRecord and the create-check in upsertRecord
'update'updateRecord and the update-check in upsertRecord
'delete'destroyRecord (soft-delete)
'force_delete'forceDeleteRecord (permanent delete — independent, no fallback to 'delete')
'restore'restoreRecord

The value for each action can be a single permission string or an array of strings. Any one matching permission grants access (same OR logic used by the standard permission resolver).

Config example:

php
'items' => new RecordTableType(
    table: 'items',
    pmsName: 'item',  // still used for actions not listed in permissions
    permissions: [
        'read'    => 'view_list_item',
        'create'  => 'insert_new_item',
        'update'  => 'update_existing_item',
        'delete'       => 'remove_item',
        'force_delete' => 'permanently_remove_item',
        'restore'      => 'restore_item',
        // partial overrides also work — e.g. only override 'read':
        // 'read' => ['view_item', 'admin_access'],
    ],
),

Backward compatibility: When permissions is null (the default), all actions use the existing pmsName-based permission generation unchanged.

Triggers ​

  • beforeRead, afterRead, beforeCreate, afterCreate, beforeUpdate, afterUpdate, beforeDelete, afterDelete, beforeRestore, afterRestore (RecordTableTriggerType|array|null, default: null): Lifecycle triggers. Each value can be:
    • a RecordTableTriggerType instance,
    • a single array trigger config (['class' => ..., 'functionName' => ..., 'description' => ...]),
    • or an array of trigger configs to run sequentially.

Class-Based Configuration (Lazy Loading) ​

For large applications with many tables or complex schemas, you can define configurations in separate classes. This improves performance by only loading the necessary configuration for the requested endpoint (Lazy Loading).

1. Table Configuration ​

Create a class extending Mylekha\RecordApi\Resources\RecordResource:

php
namespace App\Api\Tables;

use Mylekha\RecordApi\Resources\RecordResource;
use Mylekha\RecordApi\Types\RecordTableType;

class UserTable extends RecordResource
{
    public function configure(): RecordTableType
    {
        return new RecordTableType(
            table: 'users',
            canCreate: true,
            canUpdate: true,
            canDelete: false,
            // ...
        );
    }
}

2. Global Function Configuration ​

Create a class extending Mylekha\RecordApi\Resources\GlobalFunction:

php
namespace App\Api\Functions\Auth;

use Mylekha\RecordApi\Resources\GlobalFunction;
use Mylekha\RecordApi\Types\RecordFunctionType;

class LoginFunction extends GlobalFunction
{
    public function configure(): RecordFunctionType
    {
        return new RecordFunctionType(
            httpMethod: 'POST',
            class: \App\Services\AuthService::class,
            functionName: 'login',
            payloadSchema: [ ... ]
        );
    }
}

3. Registration ​

Register your classes in config/record.php or via SchemaRegistry::register():

php
// config/record.php
return [
    'tables' => [
        'users' => \App\Api\Tables\UserTable::class,
    ],
    'global_functions' => [
        'auth/login' => \App\Api\Functions\Auth\LoginFunction::class,
    ],
];

Type Reference ​

RecordTablePublic ​

Legacy public access flags for a table. New configurations should use isAuthRead and isAuthWrite on RecordTableType.

  • read (bool, default: false): Allows unauthenticated read actions (read, view).
  • write (bool, default: false): Allows unauthenticated write actions (create, update, delete, restore).
php
use Mylekha\RecordApi\Types\RecordTablePublic;

$public = new RecordTablePublic(
    read: true,
    write: false,
);

RecordTableTriggerType ​

Trigger configuration for table lifecycle events.

  • class (string, required): Trigger handler class name.
  • functionName (string, required): Static method to call on the class.
  • description (?string, default: null): Optional description.
php
use Mylekha\RecordApi\Types\RecordTableTriggerType;

$beforeCreate = new RecordTableTriggerType(
    class: \App\Record\Triggers\InvoiceTriggers::class,
    functionName: 'beforeCreate',
);

RecordFunctionType ​

Defines a callable RPC endpoint config (table RPC or global RPC).

  • httpMethod (array|string|RecordFunctionMethodEnum, required): Allowed HTTP methods.
  • class (string, required): Handler class.
  • functionName (string, required): Method name on handler class.
  • pmsName (array|string|null, default: null): Permission(s). When null, the function is public (no permission check).
  • disableCache (bool, default: false): Disable caching for this function.
  • cacheTTL (?int, default: null): Custom cache TTL (seconds). When set, overrides the default cache TTL.
  • clearCacheTables (array|string|null, default: null): Tables to clear after successful write methods (POST, PUT, PATCH, DELETE). If omitted for table functions, the current table is cleared.
  • description (?string, default: null): Optional description.
  • querySchema, payloadSchema, responseSchema (?array, default: null): Optional schema metadata used by OpenAPI generation.
php
use Mylekha\RecordApi\Types\RecordFunctionType;

$function = new RecordFunctionType(
    httpMethod: ['POST'],
    class: \App\Services\ReportService::class,
    functionName: 'generate',
    pmsName: 'view_report',
    disableCache: false,
    clearCacheTables: ['reports'],
    description: 'Generate a report',
);

RecordBelongsToType ​

Belongs-to relationship configuration.

  • table (string, required): Related table name.
  • type (RecordRelationshipsEnum, default: RecordRelationshipsEnum::BELONGS_TO)
  • foreignKey (?string, default: null): FK column on the source table.
  • ownerKey (?string, default: 'id'): PK column on the target table.
php
use Mylekha\RecordApi\Types\RecordBelongsToType;
use Mylekha\RecordApi\Enums\RecordRelationshipsEnum;

$customer = new RecordBelongsToType(
    table: 'customers',
    type: RecordRelationshipsEnum::BELONGS_TO,
    foreignKey: 'customer_id',
    ownerKey: 'id',
);

RecordHasManyType ​

Has-many relationship configuration (also used for nested writes when enabled).

  • table (string, required): Related table name.
  • foreignKey (string, required): FK column on the related table pointing back to parent.
  • type (RecordRelationshipsEnum, default: RecordRelationshipsEnum::HAS_MANY)
  • localKey (string, default: 'id'): Parent key column.
  • with (?array, default: []): Default nested includes hint.
  • allowCreate, allowUpdate, allowDelete (bool, default: true): Controls nested write operations for this relationship.
php
use Mylekha\RecordApi\Types\RecordHasManyType;
use Mylekha\RecordApi\Enums\RecordRelationshipsEnum;

$items = new RecordHasManyType(
    table: 'invoice_items',
    foreignKey: 'invoice_id',
    type: RecordRelationshipsEnum::HAS_MANY,
    localKey: 'id',
    with: [],
    allowCreate: true,
    allowUpdate: true,
    allowDelete: true,
);

RecordHasManyThroughType ​

Has-many-through relationship configuration.

  • table (string, required): Target table name.
  • through (string, required): Intermediate table name.
  • firstKey (string, required): FK on intermediate table referencing the source model.
  • secondLocalKey (string, default: ''): FK on intermediate table referencing the target model.
  • secondKey (string, default: 'id'): PK on the target table.
  • localKey (string, default: 'id'): PK on the source table.
  • orderBy (array, default: ['date' => 'desc']): Sort configuration.
  • type (RecordRelationshipsEnum, default: RecordRelationshipsEnum::HAS_MANY_THROUGH)
  • allowCreate, allowUpdate, allowDelete (bool, default: true): Controls nested write operations for this relationship.
php
use Mylekha\RecordApi\Types\RecordHasManyThroughType;
use Mylekha\RecordApi\Enums\RecordRelationshipsEnum;

$payments = new RecordHasManyThroughType(
    table: 'payments',
    through: 'invoice_payments',
    firstKey: 'invoice_id',
    secondKey: 'id',
    localKey: 'id',
    secondLocalKey: 'payment_id',
    orderBy: ['payment_date' => 'desc'],
    type: RecordRelationshipsEnum::HAS_MANY_THROUGH,
    allowCreate: true,
    allowUpdate: true,
    allowDelete: false, // Prevent deleting payments via this relationship
);

RecordMetaBelongsToManyType ​

Many-to-many relationship configuration with optional pivot details.

  • related (string, required): Related model class name or related table name.
  • type (RecordRelationshipsEnum, default: RecordRelationshipsEnum::BELONGS_TO_MANY)
  • table (?string, default: null): Pivot table.
  • foreignPivotKey, relatedPivotKey (?string, default: null): Pivot key columns.
  • parentKey, relatedKey (?string, default: null): Key columns on source/target tables.
  • relation (?string, default: null): Morph relation name (when using morph pivot patterns).
  • withPivot (array, default: []): Extra pivot columns to return.
  • wherePivot (array, default: []): Pivot constraints as ['pivot_col' => value].
  • withTimestamps (bool, default: false): Include pivot timestamps.
  • select (array, default: []): Columns to select from related table.
  • pivotWhere (array, default: []): Legacy format; converted into wherePivot if wherePivot is empty.
  • allowCreate, allowUpdate, allowDelete (bool, default: true): Controls nested write operations (attaching/detaching/updating).
php
use Mylekha\RecordApi\Types\RecordMetaBelongsToManyType;
use Mylekha\RecordApi\Enums\RecordRelationshipsEnum;

$roles = new RecordMetaBelongsToManyType(
    related: 'roles',
    type: RecordRelationshipsEnum::BELONGS_TO_MANY,
    table: 'user_roles',
    foreignPivotKey: 'user_id',
    relatedPivotKey: 'role_id',
    withPivot: ['assigned_at'],
    wherePivot: [],
    withTimestamps: true,
    select: ['roles.id', 'roles.name'],
    allowCreate: true,  // Allow attaching roles
    allowUpdate: false, // Prevent updating role details
    allowDelete: true,  // Allow detaching roles
);

RecordSpatiePermissionType ​

Specialized relationship config for spatie/laravel-permission morph pivot tables.

  • Requires spatie/laravel-permission to be installed; the constructor throws if it is missing.
  • Automatically ensures model_type is included in withPivot. If teamsEnabled is true, it also adds the team key to withPivot.
php
use Mylekha\RecordApi\Types\RecordSpatiePermissionType;
use Mylekha\RecordApi\Enums\RecordRelationshipsEnum;

$userRoles = new RecordSpatiePermissionType(
    related: config('permission.models.role'),
    relation: 'model',
    type: RecordRelationshipsEnum::SPATIE_PERMISSION,
    table: config('permission.table_names.model_has_roles'),
    foreignPivotKey: config('permission.column_names.model_morph_key'),
    relatedPivotKey: 'role_id',
    teamsEnabled: true,
);

Standard CRUD Operations ​

List Records ​

http
GET /{api_prefix}/{table}

Retrieve a paginated list of records with filtering, sorting, and relationship loading.

Query Parameters ​

Pagination

  • per_page (integer, max: 100) - Items per page
  • page (integer) - Page number (offset pagination)

Search

  • s (string) - Search across searchable columns (uses full-text index when available, otherwise LIKE)

Selection & Relationships

  • select (string) - Select main columns and include relationships using parentheses syntax.
    • Example: ?select=*,customer(*),items(*,product(*))

Sorting

  • sortby (string) - Field to sort by
  • order (string: asc|desc) - Sort direction

Limiting

  • limit (integer, max: 1000) - Limit results (only applied when per_page is not provided)

Result Shape & Aggregation

  • distinct (boolean) - Apply SQL DISTINCT to the main query.
  • only_trashed (boolean) - For soft-deleted tables, return only rows where deleted_at is not null.
  • aggregate (string) - One or more aggregate expressions (comma-separated):
    • Supported functions: count, sum, avg, min, max.
    • Syntax:
      • count (no column) ⇒ COUNT(*)
      • count:column ⇒ COUNT(column)
      • sum:column, avg:column, min:column, max:column
    • Column names are validated against the table schema.
  • group_by (string) - Comma-separated list of columns to group by. Column names are validated against the table schema.

Debugging

  • X-Debug (HTTP header, boolean) - When sent as true, 1, yes, or on, responses include lazy-loading diagnostics and error debug details:
    • meta.debug.lazy_stats with the output of QueryBuilderFilters::getLazyStats():
      • total_operations
      • executed_operations
      • pending_operations
      • cache_hits
      • cache_efficiency
    • on error responses, meta.debug may include exception context (exception, exception_message, file, line).
  • record.debug (config, boolean, default: false) also enables error debug details globally without needing X-Debug.
  • When debug mode is enabled (via config or header), error responses are also written to Laravel log (Log::error) with request context and error metadata.

Filter Operators Filters are usually passed as {column}={operator}.{value} (operators validated against the table schema). Some operators support a value-less shorthand form for null:

  • is.null, is_not.null or simply is, is_not (equivalent to IS NULL / IS NOT NULL)
  • eq.{value}, neq.{value}, in.{a,b,c}, not_in.{a,b,c}
  • like.{value}, contains.{value}, not_like.{value}, starts_with.{value}, ends_with.{value}, regex.{pattern}
  • ilike.{value}, match.{pattern}, imatch.{pattern}
  • gt.{value}, gte.{value}, lt.{value}, lte.{value}
  • between.{start,end}, not_between.{start,end}
  • date_eq.{YYYY-MM-DD}, date_gt.{YYYY-MM-DD}, date_gte.{YYYY-MM-DD}, date_lt.{YYYY-MM-DD}, date_lte.{YYYY-MM-DD}
  • Full-text operators: fts.{query}, plfts.{query}, phfts.{query}, wfts.{query}
  • Native Postgres operators: cs.{value}, cd.{value}, ov.{value}, sl.{value}, sr.{value}, nxl.{value}, nxr.{value}, adj.{value}
  • empty.null, not_empty.null or simply empty, not_empty:
    • On text columns, empty ⇔ IS NULL OR = '', not_empty ⇔ IS NOT NULL AND != ''.
    • On non-text columns (e.g. integers), empty ⇔ IS NULL, not_empty ⇔ IS NOT NULL.
  • Negated style is also supported using expression syntax:
    • not.eq.5, not.in.(1,2,3), not.like.ACME, not.fts.invoice
  • any / all modifiers are supported in expression syntax:
    • name=like(any).{ACME,SHOP}
    • name=ilike(all).{spx,admin}

Grouped Logic

  • Top-level query params continue to behave as AND.
  • You can add grouped logic params:
    • or=(...)
    • and=(...)
  • Inside grouped logic, each condition uses expression syntax:
    • {column}.{operator}.{value}
    • Example: id.eq.5, balance_due.gt.0, id.in.(5,6,9)

Examples:

  • vendor_id=eq.27&or=(balance_due.gt.0,id.eq.5)
    • Interpreted as: vendor_id = 27 AND (balance_due > 0 OR id = 5)
  • vendor_id=eq.27&and=(or(balance_due.gt.0,id.eq.5),id.neq.2)
    • Interpreted as: vendor_id = 27 AND ((balance_due > 0 OR id = 5) AND id != 2)
  • id=in.(5,6,9) and id=not_in.(5,6,9) are supported in addition to legacy list style (id=in.5,6,9).
  • status=eq.open&and=(or(total_amount.gte.1000,total_amount.is.null),or(currency.eq.USD,currency.eq.KHR),issued_at.date_gte.2026-01-01,issued_at.date_lte.2026-12-31)
    • Interpreted as: status = 'open' AND ((total_amount >= 1000 OR total_amount IS NULL) AND (currency = 'USD' OR currency = 'KHR') AND issued_at >= '2026-01-01' AND issued_at <= '2026-12-31')
  • customer_id=eq.18&or=(and(balance_due.gt.0,due_date.lt.2026-03-31),and(id.in.(5,6,9),ref_number.like.BILL-2026))
    • Interpreted as: customer_id = 18 AND ((balance_due > 0 AND due_date < '2026-03-31') OR (id IN (5,6,9) AND ref_number LIKE '%BILL-2026%'))
  • vendor_id=eq.27&or=(vendor.display_name.like.Acme,items.account_code.in.(4000,4010),items.amount.gt.0)
    • Example of grouped logic including relationship filters (vendor.*, items.*) in the same OR expression.

Notes:

  • For grouped logic, use comma-separated expressions inside the group.
  • Do not use = or & inside or=(...) / and=(...).
  • For URL safety, grouped logic can also be sent in decoded form:
    • or=(balance_due.gt.0,id.in.(5,6,9))
  • Complex grouped examples are best URL-encoded when sent from frontend clients.
  • If an operator is not supported by the current database driver, API returns validation error with an explicit message.

When aggregate is present and valid, the list endpoint returns aggregated rows instead of paginated records. The response still follows the standard shape, with:

  • data: Aggregated rows (including group_by columns and aggregate aliases like count_id).
  • meta.total: Number of aggregated rows.
  • meta.group_by: Grouped columns (when provided).
  • meta.aggregate: List of aggregate operations with function, column, and alias metadata.

Example Request ​

http
GET /api/v1/invoices?per_page=25&sortby=created_at&order=desc&select=*,customer(*),items(*,product(*))&status=eq.pending&total=gte.100&s=invoice
Authorization: Bearer {access_token}

Response Format ​

json
{
  "success": true,
  "error_code": 0,
  "data": [
    {
      "id": 1,
      "invoice_number": "INV-001",
      "total": 150.0,
      "status": "pending",
      "created_at": "2024-01-15T10:30:00Z",
      "customer": {
        "id": 5,
        "name": "John Doe",
        "email": "john@example.com"
      },
      "items": [
        {
          "id": 10,
          "description": "Product A",
          "quantity": 2,
          "price": 75.0
        }
      ]
    }
  ],
  "meta": {
    "request_id": "req_abc123def456",
    "page": 1,
    "per_page": 25,
    "total": 150
  }
}

Status Codes ​

  • 200 - Success
  • 401 - Unauthorized
  • 403 - Forbidden
  • 404 - Resource not available (table not configured or disabled)
  • 500 - Server error

Get Single Record ​

http
GET /{api_prefix}/{table}/{id}

Retrieve a single record by its primary key.

Query Parameters ​

  • select (string) - Select main columns and include relationships using parentheses syntax
    • Example: ?select=*,customer(*),items(*,product(*))

Example Request ​

http
GET /api/v1/invoices/123?select=*,customer(*),items(*),payments(*)
Authorization: Bearer {access_token}

Response Format ​

json
{
  "success": true,
  "error_code": 0,
  "data": {
    "id": 123,
    "invoice_number": "INV-123",
    "total": 250.00,
    "status": "paid",
    "customer": {
      "id": 5,
      "name": "John Doe"
    },
    "items": [...],
    "payments": [...]
  },
  "meta": {
    "request_id": "req_abc123def456"
  }
}

Status Codes ​

  • 200 - Success
  • 401 - Unauthorized
  • 403 - Forbidden
  • 404 - Not found (table not configured/disabled or record not found)
  • 500 - Server error

Create Record ​

http
POST /{api_prefix}/{table}

Create a new record.

If a createValidator is defined for the target table in config/record.php, the request body is validated using that validator before any database changes. On validation failure, the endpoint returns 422 with detailed error messages.

Create With Relationships In Response ​

To return related records in the response of the create call, pass a nested select query parameter. Relationship selection uses parentheses: relationship(columns,childRelationship(...)).

Example (return customer and items.product after creating an orders record):

bash
curl --location 'http://127.0.0.1:8000/api/v1/orders?select=*,customer(*),items(*,product(*))' \
  --header 'Content-Type: application/json' \
  --header 'Authorization: Bearer {access_token}' \
  --data '{
    "customer_id": 5,
    "status": "draft",
    "total": 300.00
  }'

Request Body ​

JSON object with field values:

json
{
  "invoice_number": "INV-124",
  "customer_id": 5,
  "total": 300.0,
  "status": "draft",
  "items": [
    {
      "description": "Product B",
      "quantity": 3,
      "price": 100.0
    }
  ]
}

Response Format ​

json
{
  "success": true,
  "error_code": 0,
  "data": {
    "id": 124,
    "invoice_number": "INV-124",
    "customer_id": 5,
    "total": 300.0,
    "status": "draft",
    "created_at": "2024-01-15T11:00:00Z",
    "updated_at": "2024-01-15T11:00:00Z"
  },
  "meta": {
    "request_id": "req_abc123def456"
  }
}

Status Codes ​

  • 200 - Success
  • 401 - Unauthorized
  • 403 - Forbidden
  • 404 - Resource not available (table not configured or disabled)
  • 422 - Validation error (table validator or request validation)
  • 500 - Server error

Upsert Record ​

http
POST /{api_prefix}/{table}/upsert

Create a new record or update an existing one based on matching columns.

Query Parameters ​

  • match_on (string, required) - Comma-separated list of columns to use for matching records.
    • Example: ?match_on=sku or ?match_on=email,tenant_id

Request Body ​

JSON object with field values:

json
{
  "sku": "PROD-001",
  "name": "Wireless Mouse",
  "price": 29.99
}

Response Format ​

json
{
  "success": true,
  "error_code": 0,
  "data": {
    "id": 125,
    "payload": {
      "sku": "PROD-001",
      "name": "Wireless Mouse",
      "price": 29.99
    }
  },
  "meta": {
    "request_id": "req_abc123def456"
  }
}

Hooks and Validation ​

Before writing, the row is looked up by match_on. If it does not exist the upsert is a create: the global and table beforeCreate triggers, createValidator and default create validation run exactly as for POST /{table}. If it exists the upsert is an update: the global and table beforeUpdate triggers (with the existing row's id in the context), updateValidator and default update validation run exactly as for PUT /{table}/{id}. A trigger or validator that rejects the row refuses the upsert with the same response the create/update would give, and nothing is written.

Status Codes ​

  • 200 - Success
  • 404 - Resource not available (table not configured, or canUpsert is false)
  • 422 - Validation error (missing match_on or invalid payload)

Update Record ​

http
PUT /{api_prefix}/{table}/{id}
PATCH /{api_prefix}/{table}/{id}

Update an existing record. PUT expects complete data, PATCH allows partial updates.

If an updateValidator is defined for the target table, the request is validated with access to both the incoming payload and the current record ID. Validation failures return 422 with error details.

Request Body ​

json
{
  "status": "sent",
  "total": 275.0
}

Response Format ​

json
{
  "success": true,
  "error_code": 0,
  "data": {
    "id": 124,
    "invoice_number": "INV-124",
    "status": "sent",
    "total": 275.0,
    "updated_at": "2024-01-15T11:30:00Z"
  },
  "meta": {
    "request_id": "req_abc123def456"
  }
}

Status Codes ​

  • 200 - Success
  • 401 - Unauthorized
  • 403 - Forbidden
  • 404 - Not found (table not configured/disabled or record not found)
  • 422 - Validation error (table validator or request validation)
  • 500 - Server error

Delete Record ​

http
DELETE /{api_prefix}/{table}/{id}

Delete a record (soft delete if enabled, otherwise hard delete).

If a deleteValidator is defined for the target table, the request is validated (typically against the ID and context) before the record is deleted. Validation failures return 422 with error details.

Response Format ​

json
{
  "success": true,
  "error_code": 0,
  "data": {
    "deleted": 1
  },
  "meta": {
    "request_id": "req_abc123def456"
  }
}

Status Codes ​

  • 200 - Success
  • 401 - Unauthorized
  • 403 - Forbidden
  • 404 - Not found (table not configured/disabled or record not found)
  • 422 - Validation error (table validator or request validation)
  • 500 - Server error

Restore Record ​

http
POST /{api_prefix}/{table}/{id}/restore

Restore a soft-deleted record (only available for tables with soft deletes enabled).

Response Format ​

json
{
  "success": true,
  "error_code": 0,
  "data": {
    "restored": 1
  },
  "meta": {
    "request_id": "req_abc123def456"
  }
}

Status Codes ​

  • 200 - Success
  • 401 - Unauthorized
  • 403 - Forbidden
  • 404 - Not found (table not configured/disabled or record not found)
  • 500 - Server error

Force Delete Record ​

http
DELETE /{api_prefix}/{table}/{id}/force

Permanently delete a record (bypasses soft delete).

Response Format ​

json
{
  "success": true,
  "error_code": 0,
  "data": {
    "deleted": 1
  },
  "meta": {
    "request_id": "req_abc123def456"
  }
}

Status Codes ​

  • 200 - Success
  • 401 - Unauthorized
  • 403 - Forbidden
  • 404 - Not found (table not configured/disabled or record not found)
  • 500 - Server error

Global RPC Functions ​

Global RPC functions allow you to define custom endpoints that are not tied to a specific table. These are useful for system-wide operations like authentication, reporting, or utility functions.

Global functions are configured in config/record.php under the global_functions key.

Public Global Functions ​

You can create public endpoints by setting pmsName to null. These functions can be accessed without authentication.

Configuration Example:

php
'global_functions' => [
    'login' => [
        'httpMethod' => ['POST'],
        'class' => \App\Http\Controllers\AuthController::class,
        'functionName' => 'login',
        'description' => 'User login',
        'pmsName' => null, // Public access
        'payloadSchema' => [ ... ],
        'responseSchema' => [ ... ],
    ],
],

Request:

bash
curl -X POST http://localhost:8000/api/v1/rpc/login \
  -H "Content-Type: application/json" \
  -d '{"email": "user@example.com", "password": "password"}'

Protected Global Functions ​

By providing a pmsName, the function requires authentication and the user must have the specified permission(s).

Configuration Example:

php
'global_functions' => [
    'system_stats' => [
        'httpMethod' => ['GET'],
        'class' => \App\Services\StatsService::class,
        'functionName' => 'getSystemStats',
        'description' => 'Get system statistics',
        'pmsName' => 'view_system_stats', // Requires auth & permission
    ],
],

Request:

bash
curl -X GET http://localhost:8000/api/v1/rpc/system_stats \
  -H "Authorization: Bearer {token}"

Routing ​

Global functions are registered with high priority, so a global function named login will take precedence over a table named login. However, they are constrained to the configured keys to avoid shadowing valid table routes unnecessarily.


Nested Relationships ​

You can perform Create and Update operations on a record and its related records in a single request. This is supported for hasMany relationships configured in config/record.php.

Nested Create ​

Create a parent record along with its related child records.

Request Body ​

json
{
  "customer_name": "Tech Corp",
  "date": "2023-12-23",
  "total": 1500.0,
  "status": "draft",
  "items": [
    {
      "product_name": "Laptop",
      "quantity": 1,
      "price": 1200.0,
      "total": 1200.0
    },
    {
      "product_name": "Mouse",
      "quantity": 2,
      "price": 150.0,
      "total": 300.0
    }
  ]
}

Example Request ​

bash
curl --location 'http://127.0.0.1:8000/api/v1/invoices' \
--header 'Content-Type: application/json' \
--header 'Authorization: Bearer {token}' \
--data '{
    "customer_name": "Tech Corp",
    "date": "2023-12-23",
    "total": 1500.00,
    "status": "draft",
    "items": [
        {
            "product_name": "Laptop",
            "quantity": 1,
            "price": 1200.00,
            "total": 1200.00
        }
    ]
}'

Nested Update ​

Update a parent record and manage its relationships simultaneously. You can:

  • Update existing children (provide id).
  • Create new children (omit id).
  • Delete existing children (provide id and _delete: true or _destroy: true).

Request Body ​

json
{
  "total": 1750.0,
  "items": [
    {
      "id": 1,
      "quantity": 2,
      "total": 2400.0
    },
    {
      "id": 2,
      "_delete": true
    },
    {
      "product_name": "Keyboard",
      "quantity": 5,
      "price": 50.0,
      "total": 250.0
    }
  ]
}

Example Request ​

bash
curl --location --request PUT 'http://127.0.0.1:8000/api/v1/invoices/123' \
--header 'Content-Type: application/json' \
--header 'Authorization: Bearer {token}' \
--data '{
    "total": 1750.00,
    "items": [
        {
            "id": 1,
            "quantity": 2,
            "total": 2400.00
        },
        {
            "id": 2,
            "_delete": true
        },
        {
            "product_name": "Keyboard",
            "quantity": 5,
            "price": 50.00,
            "total": 250.00
        }
    ]
}'

Bulk Operations ​

Bulk operations allow you to perform Create, Update, or Delete actions on multiple records in a single HTTP request. This is significantly more efficient than sending individual requests for large datasets.

A single bulk request accepts at most bulk_max records (config/record.php, default 1000).

Triggers & Validation in Bulk Operations ​

  • Validation: Table-level validators (createValidator, updateValidator, deleteValidator) are currently not automatically applied to bulk operations. You should validate your payload before sending.
  • Triggers: Table-level triggers (beforeCreate, afterCreate, beforeUpdate, afterUpdate, beforeDelete, afterDelete) are executed for each individual item in the bulk batch.
    • This allows you to maintain consistent business logic (e.g., setting default values, syncing with external systems) regardless of whether a record is created individually or in bulk.

Legacy Bulk Operation ​

http
POST /{api_prefix}/{table}/bulk

Auto-detects operation type based on request data structure.

Bulk Create ​

http
POST /{api_prefix}/{table}/bulk/create

Create multiple records in a single request. All rows are written in one transaction.

Request Body ​

A JSON array of records (at most bulk_max, default 1000). Do not send primary keys.

json
[
  { "name": "Product A", "price": 100.0 },
  { "name": "Product B", "price": 150.0 }
]

Response Format ​

json
{
  "success": true,
  "error_code": 0,
  "data": [
    { "id": 10, "name": "Product A", "price": 100.0 },
    { "id": 11, "name": "Product B", "price": 150.0 }
  ],
  "meta": {
    "request_id": "f9c4d1e2-9b0c-4f8a-9b8a-123456789abc",
    "affected": 2
  }
}

Bulk Update ​

http
POST /{api_prefix}/{table}/bulk/update

Update multiple records by primary key, in one transaction.

Request Body ​

A JSON array; every item needs its primary key and at least one field to change.

json
[
  { "id": 10, "price": 110.0 },
  { "id": 11, "price": 160.0 }
]

The response has the same shape as bulk create: the updated rows in data and meta.affected.

Bulk Delete ​

http
POST /{api_prefix}/{table}/bulk/delete

Delete multiple records by primary key, in one transaction.

Request Body ​

A JSON array of ids, or of objects with the primary key:

json
[10, 11, 12]
json
[{ "id": 10 }, { "id": 11 }]

Bulk Upsert ​

http
POST /{api_prefix}/{table}/bulk/upsert

Bulk create or update records based on matching columns.

Each row is looked up by match_on and then runs the same default validation and global/table beforeCreate (new row) or beforeUpdate (existing row, with its id) hooks as bulk/create and bulk/update. Rows are written one by one inside a single transaction, so a trigger sees the rows written before it, and a rejected row rolls back the whole batch. Returns 404 when canUpsert is false.

Items sent to POST /{api_prefix}/{table}/bulk with "operation": "upsert" are also refused (404, nothing written) when canUpsert is false, and run the table beforeCreate / beforeUpdate trigger of the write they perform, like the create and update items of that endpoint.

Query Parameters ​

  • match_on (string, required) - Comma-separated list of columns to use for matching records.
    • Example: ?match_on=sku

Request Body ​

JSON array of objects.

json
[
  {
    "sku": "PROD-001",
    "name": "Wireless Mouse",
    "price": 29.99
  },
  {
    "sku": "PROD-002",
    "name": "Mechanical Keyboard",
    "price": 89.99
  }
]

Response Format ​

json
{
  "success": true,
  "error_code": 0,
  "data": {
    "count": 2
  },
  "meta": {
    "request_id": "req_abc123def456",
    "total": 2
  }
}

Audit Management ​

Audit Formatting ​

Audit title, subject, and recap labels are configurable via config/audit.php:

  • audit.subject_fields: Ordered list of fields used as the subject (empty list yields blank subject).
  • audit.entity_labels: Per-entity label overrides (falls back to auto-generated labels).
  • audit.recap_entities: Entities that use the detailed recap formatter.
  • audit.main_field_labels: Field label map used by recap output.
  • audit.recap_max_fields: Limits generic recap length and appends “and N more”.
  • audit.log_relationships: Includes relationship snapshots in audit data when enabled.

Get Audit Logs ​

http
GET /{api_prefix}/audit/logs

Retrieve audit logs with filtering options.

Query Parameters ​

  • entity_type (string, required) - Filter by entity type (table name, e.g. invoices)
  • entity_id (integer, required) - Filter by specific entity ID
  • limit (integer) - Max results (default: 50, max: 100)

Example Request ​

http
GET /api/v1/audit/logs?entity_type=invoices&entity_id=123&limit=20
Authorization: Bearer {access_token}

Response Format ​

json
{
  "success": true,
  "error_code": 0,
  "data": [
    {
      "id": 1001,
      "entity_type": "invoices",
      "entity_id": 123,
      "event": "updated",
      "old_data": "{\\n  \\\"id\\\": 123,\\n  \\\"status\\\": \\\"draft\\\"\\n}",
      "new_data": "{\\n  \\\"id\\\": 123,\\n  \\\"status\\\": \\\"sent\\\"\\n}",
      "subject": "INV-001",
      "recap": "",
      "user_id": 5,
      "entity_name": "invoices",
      "metadata": "{\\n  \\\"change_summary\\\": { ... },\\n  \\\"field_changes\\\": { ... }\\n}",
      "created_at": "2024-01-15T11:30:00Z"
    }
  ]
}

Note: old_data, new_data, and metadata are stored as JSON strings in the database. Clients can JSON.parse / json_decode them when needed.

Get Audit Statistics ​

http
GET /{api_prefix}/audit/stats

Get audit statistics and metrics.

Query Parameters ​

  • entity_type (string) - Filter by entity type (table name)
  • entity_id (integer) - Filter by specific entity ID
  • start_date (date: Y-m-d) - Filter from date
  • end_date (date: Y-m-d) - Filter to date (must be >= start_date)
  • event (string) - Filter by event type (created, updated, deleted, login, logout, failed_login)

Response Format ​

json
{
  "success": true,
  "error_code": 0,
  "data": {
    "total_logs": 45,
    "actions_breakdown": {
      "created": 10,
      "updated": 30,
      "deleted": 5
    },
    "top_users": [{ "user_name": "John Admin", "count": 20 }],
    "entity_types": {
      "invoices": 45
    }
  }
}

Get Field Timeline ​

http
GET /{api_prefix}/audit/field-timeline

Get timeline of changes for a specific field.

Query Parameters (Required) ​

  • entity_type (string) - Entity type
  • entity_id (integer) - Entity ID
  • field (string) - Field name

Optional Parameters ​

  • limit (integer) - Max results (default: 50, max: 50)

Response Format ​

json
{
  "success": true,
  "error_code": 0,
  "data": [
    {
      "id": 1001,
      "changed_at": "2024-01-15T11:30:00Z",
      "old_value": "draft",
      "new_value": "sent",
      "change_type": "updated",
      "data_type": "string",
      "user_name": "John Admin",
      "event": "updated"
    },
    {
      "id": 990,
      "changed_at": "2024-01-10T09:15:00Z",
      "old_value": null,
      "new_value": "draft",
      "change_type": "created",
      "data_type": "string",
      "user_name": "Jane User",
      "event": "created"
    }
  ]
}

Get Field Statistics ​

http
GET /{api_prefix}/audit/field-stats

Get statistics for a specific field across entities.

Query Parameters (Required) ​

  • entity_type (string) - Entity type
  • entity_id (integer) - Entity ID
  • field (string) - Field name

Response Format ​

json
{
  "success": true,
  "error_code": 0,
  "data": {
    "total_changes": 150,
    "first_changed_at": "2024-01-01T08:00:00Z",
    "last_changed_at": "2024-01-15T11:30:00Z",
    "changes_by_user": {
      "John Admin": 80,
      "Jane User": 70
    },
    "field_name": "status"
  }
}

Create Audit Log ​

http
POST /{api_prefix}/audit/logs

Manually create an audit log entry.

Request Body ​

json
{
  "event": "updated",
  "entity_type": "invoices",
  "entity_name": "invoices",
  "entity_id": 123,
  "subject": "INV-001",
  "recap": "Status changed via API",
  "metadata": {
    "id": 123,
    "old_data": { "status": "draft" },
    "new_data": { "status": "sent" }
  }
}

Note: If metadata.old_data and metadata.new_data are provided, they are used as the explicit old/new snapshots for the audit record. If omitted for updates, the system may infer old_data from the most recent new_data stored for the same entity.

Get Specific Audit Log ​

http
GET /{api_prefix}/audit/logs/{id}

Retrieve a specific audit log by ID.

Cleanup Audit Logs ​

http
DELETE /{api_prefix}/audit/cleanup

Clean up old audit logs (admin only - requires can:manage-audit-logs permission).

Query Parameters ​

  • days (integer) - Retention period in days
  • dry_run (boolean) - Preview what would be deleted

Custom Functions ​

Global Functions ​

http
GET|POST|PUT|PATCH|DELETE /{api_prefix}/rpc/{functionName}

Execute global custom functions defined in config/record.php.

Examples ​

http
# Simple global function
GET /api/v1/rpc/system_stats

# Parameterized global function
POST /api/v1/rpc/generate_report
Content-Type: application/json
{
  "report_type": "monthly",
  "date_range": "2024-01"
}

Table Functions ​

http
GET|POST|PUT|PATCH|DELETE /{api_prefix}/{table}/rpc/{functionName}

Execute table-specific custom functions.

Examples ​

http
# Simple table function
GET /api/v1/invoices/rpc/calculate_totals

# Parameterized table function
POST /api/v1/users/rpc/send_notification
Content-Type: application/json
{
  "message": "Welcome to our platform!",
  "type": "welcome"
}

Function Caching ​

Caching is only applied for GET requests and when record.cache.enabled is true.

Table functions

  • Caching is enabled by default (disableCache: false).
  • Table cache can be disabled globally for a table using record.cache.per_table[table] = false.
  • Default TTL uses record.cache.per_table_ttl[table] when set; otherwise record.cache.ttl.
  • Set cacheTTL in the function config to override the computed TTL for this function.
  • For write methods (POST, PUT, PATCH, DELETE), table function cache for the executed function is invalidated automatically.
  • For write methods (POST, PUT, PATCH, DELETE), table functions automatically clear cache for the current table after a successful response. Use clearCacheTables to clear additional tables.

Global functions

  • Caching is enabled by default (disableCache: false).
  • Default TTL uses record.cache.ttl.
  • Set cacheTTL in the function config to override the default TTL for this function.
  • For write methods (POST, PUT, PATCH, DELETE), global function cache for the executed function is invalidated automatically.
  • Global functions can additionally clear table caches by setting clearCacheTables.

Manual cache clear

You can clear a table cache manually from any controller, job, or command:

php
use Mylekha\RecordApi\Services\RecordCacheService;

$service = app(RecordCacheService::class);
$service->clearTableCache('settings', $tenantId);
$service->clearCacheForTables(['settings', 'users'], $tenantId);

Controller cache example

php
use Illuminate\Http\Request;
use Mylekha\RecordApi\Services\QueryCacheService;
use Mylekha\RecordApi\Services\RecordCacheService;
use Mylekha\RecordApi\Services\RecordService;

public function list(Request $request)
{
    $filters = $request->query();
    $cacheKey = QueryCacheService::tableKey('settings', $filters, [], 1, 50);

    return QueryCacheService::remember($cacheKey, function () use ($request) {
        return app(RecordService::class)->listRecords($request, 'settings');
    }, 600);
}

public function update(Request $request, RecordCacheService $cacheService)
{
    app(RecordService::class)->updateRecord($request, 'settings', 1);
    $tenantId = $request->header('X-Tenant-Id');
    $cacheService->clearTableCache('settings', $tenantId);

    return response()->json(['ok' => true]);
}

Error Responses ​

Standard Success Format ​

json
{
  "success": true,
  "error_code": 0,
  "data": {},
  "meta": {
    "request_id": "req_abc123def456"
  }
}

Standard Error Format ​

json
{
  "success": false,
  "error_code": 1000,
  "message": "Validation failed",
  "errors": {
    "email": ["The email field is required."],
    "price": ["The price must be a number."]
  },
  "meta": {
    "request_id": "req_abc123def456"
  }
}

error_code is a stable, machine-friendly code that complements the HTTP status:

  • On success responses it is always the success code.
  • On error responses it is derived from the HTTP status (unauthorized, forbidden, not found, validation, server error, etc.), unless a downstream handler explicitly sets error_code in its JSON body, in which case that value is preserved.

Error Codes ​

The package uses a fixed set of numeric error_code values to make client-side handling and analytics easier. These codes are stable across versions and map to logical error categories:

  • 0 – SUCCESS: Request completed successfully.
  • 10000 – GENERAL_ERROR: Generic error when no more specific category applies.
  • 10001 – INVALID_TENANT_ID: Tenant identifier is missing, malformed, or does not match the current context.
  • 10002 – INVALID_ACCESS: Unauthorized access (typically HTTP 401) – missing or invalid authentication for the requested resource.
  • 10003 – INVALID_TOKEN: Authentication token is invalid (bad signature, malformed, or otherwise unusable).
  • 10004 – INVALID_REQUEST: Request payload or query parameters are invalid (commonly used for validation errors / HTTP 422).
  • 10005 – INVALID_RESOURCE: Reference to an invalid resource (e.g. invalid foreign key or unsupported table/endpoint).
  • 10006 – INVALID_PERMISSION: Permission configuration is invalid or inconsistent.
  • 10007 – INVALID_CREDENTIAL: User credentials are incorrect (login/auth failures).
  • 10008 – PERMISSION_DENIED: Authenticated user is forbidden from performing this action (typically HTTP 403).
  • 10009 – RESOURCE_NOT_FOUND: Requested resource cannot be found (table, record, or function – typically HTTP 404).
  • 10010 – INTERNAL_SERVER_ERROR: Unhandled server-side error (HTTP 500).
  • 10011 – UNKNOWN_ERROR: Error that cannot be mapped to a known category.
  • 10012 – TENANT_NOT_FOUND: Tenant does not exist or is not registered.
  • 10013 – TENANT_DISABLED: Tenant exists but is disabled / suspended.
  • 10014 – NO_TENANT_PMS_ACCESS: Current user/application has no PMS access for this tenant.
  • 10015 – TOKEN_EXPIRED: Authentication token is valid but expired.

HTTP Status Codes ​

  • 200 - Success
  • 201 - Created
  • 204 - Deleted (no content)
  • 400 - Bad Request
  • 401 - Unauthorized
  • 403 - Forbidden
  • 404 - Not Found
  • 422 - Validation Error
  • 429 - Rate Limited (throttle middleware)
  • 500 - Server Error

Rate Limiting ​

Different endpoints have different rate limits:

  • API Reads (throttle:api-reads) - GET operations
  • API Writes (throttle:api-writes) - POST, PUT, PATCH, DELETE operations
  • API Functions (throttle:api-functions) - Custom function calls

Rate limits are configurable in your Laravel application's rate limiting configuration.

Security Considerations ​

Authentication ​

  • All endpoints require valid JWT tokens unless configured as public
  • Tokens should be included in the Authorization: Bearer {token} header

Authorization ​

  • Permission-based access control using Spatie Laravel Permission
  • Automatic tenant isolation when tenant_id column is present
  • Special permissions for restricted access patterns

Data Protection ​

  • Automatic SQL injection prevention
  • Input validation and sanitization
  • Audit trail for all operations
  • Configurable field exclusion for sensitive data

Best Practices ​

  • Use HTTPS in production
  • Implement proper CORS policies
  • Monitor rate limits and adjust as needed
  • Regularly review audit logs
  • Keep JWT secrets secure and rotate them periodically

Trait QueryHelpers Documentation ​

Mylekha\RecordApi\Traits\QueryHelpers is an Eloquent-only utility for building custom ORM-based endpoints. It is not part of the Record CRUD table endpoints and does not affect how /api/v1/{table} works.

Purpose ​

  • Provide a single Eloquent scope (applyRequestFilters) that converts request query parameters into Eloquent query constraints.
  • Standardize filtering, sorting, pagination, and eager-loading patterns for custom controllers/services using Eloquent models.

Usage (Clean Architecture) ​

Model

php
namespace App\Models;

use Illuminate\Database\Eloquent\Model;
use Mylekha\RecordApi\Traits\QueryHelpers;

class Invoice extends Model
{
    use QueryHelpers;
}

Service

php
namespace App\Services;

use App\Models\Invoice;
use Illuminate\Http\Request;

class InvoiceService
{
    public function list(Request $request)
    {
        return Invoice::query()->applyRequestFilters($request);
    }
}

Controller

php
namespace App\Http\Controllers;

use App\Services\InvoiceService;
use Illuminate\Http\Request;

class InvoiceController
{
    public function __construct(private readonly InvoiceService $invoices) {}

    public function index(Request $request)
    {
        $result = $this->invoices->list($request);

        if ($result instanceof \Illuminate\Database\Eloquent\Builder) {
            return response()->json($result->get());
        }

        return response()->json($result);
    }
}

Public API ​

applyRequestFilters (Eloquent scope) ​

Signature:

php
public function scopeApplyRequestFilters(
    \Illuminate\Database\Eloquent\Builder $builder,
    \Illuminate\Http\Request $request,
    bool $isArray = false,
    string $orderBy = 'id'
)

Return behavior:

  • Returns LengthAwarePaginator when per_page is present.
  • Returns LazyCollection when lazy=true.
  • Returns array{data: mixed, total: int} when total_record=true.
  • Returns a Collection when $isArray=true.
  • Otherwise returns the modified Eloquent\Builder.

You can also use named arguments when calling the scope (PHP 8+):

php
$result = Invoice::query()->applyRequestFilters(
    request: $request,
    isArray: true,
    orderBy: 'created_at',
);

Supported Query Parameters ​

Search

  • s or search: Searches across all columns in the model table. Example: ?s=invoice.

Select (main table only)

  • select: Selects only columns from the main table (table-prefixed). Example: ?select=id,invoice_number,total.

Eager Loading

  • with: Eloquent eager-loading. Examples:
    • ?with=customer,items
    • ?with=posts.comments
    • Column-constrained: ?with=customer(id,name) or ?with=customer(*)
    • JSON array is accepted: ?with=["customer(id,name)","items"]

Sorting

  • sortby: Sort column (defaults to $orderBy, default id)
  • order: Sort direction (asc or desc, default desc)

Result Shape

  • per_page: Enables pagination.
  • lazy=true: Returns a LazyCollection.
  • limit: Limits results when $isArray=true (max 20000).
  • total_record=true: Returns { data, total } (uses limit for the data size).

Join Helpers

  • join: Table join by name. Example: ?join=customer joins customer.id = {main_table}.customer_id.
  • select_join: JSON map of {table: [columns...]}. Example: ?select_join={"customers":["name","email"]}.

Filter Operators ​

Operators are passed as {column}={operator}.{value}:

  • is.null
  • eq.{value}, neq.{value}
  • like.{value}, contains.{value}
  • gt.{value}, gte.{value}, lt.{value}, lte.{value}
  • in.{a,b,c}
  • between.{start,end}, not_between.{start,end}

Multiple columns OR (comma-separated keys):

  • ?invoice_number,reference=contains.ACME

Compare two columns:

  • ?total=compare.gt.balance

Helper Methods (Internal) ​

These are private methods used by the trait implementation:

applyFilterOperator ​

php
private function applyFilterOperator(
    \Illuminate\Database\Eloquent\Builder $builder,
    string $key,
    string $operator,
    string $queryValue,
    string $tableName
): void

parseSelectColumns ​

php
private function parseSelectColumns(string $selectParam, string $tableName): array

parseWithRelations ​

php
private function parseWithRelations(string $withParam): array

castStringToArray ​

php
private function castStringToArray(string $value): array

handleOptimizedQuery ​

php
private function handleOptimizedQuery(
    \Illuminate\Database\Eloquent\Builder $builder,
    \Illuminate\Http\Request $request
)

Released under the MIT License.