Skip to content

Backend Agent Context ​

Agent Context: mylekha/record-api (Backend / Laravel) ​

What This Package Is ​

mylekha/record-api is a config-driven REST API layer for Laravel. It auto-generates CRUD, bulk, upsert, RPC, and audit endpoints from PHP configuration objects. No per-table controllers or routes are written by hand.

How It Works ​

  1. Tables are declared as RecordTableType objects in config/record.php.
  2. The service provider reads this config and registers all routes automatically.
  3. HTTP requests flow through CoreRecordController → RecordService → hook callbacks.
  4. All responses are wrapped by RecordApiResponseService into a standard envelope.

Live Discovery ​

Always fetch GET /api/v1/docs/openapi.json to discover registered tables, columns, relationships, and functions at runtime. Do not assume or hardcode names.


Architecture Philosophy (Read This First) ​

The single principle: Config is the source of truth ​

Everything flows from RecordTableType declared in config/record.php. One config object per table gives you a complete, secured, documented API with zero boilerplate.

config/record.php
  └─ RecordTableType         ← declare a table here
       ├─ CRUD endpoints      auto-registered
       ├─ bulk / upsert       auto-registered
       ├─ RPC functions       auto-registered
       ├─ relationships       auto-embedded in responses
       ├─ validators          run on create / update / delete
       ├─ lifecycle hooks     business logic layer
       ├─ permissions         Spatie permission guard
       ├─ audit trail         automatic
       ├─ caching             automatic
       └─ multi-tenancy       automatic

The goal: adding a new API resource means adding one RecordTableType — never a controller, a route, or a model.


Architecture Rules — Follow These Always ​

TaskUse thisNever do this
Expose a DB table as APIRecordTableType in config/record.phpManual controller + route
Validate create / update / deletecreateValidator / updateValidator / deleteValidator in configForm Request, middleware, or manual $request->validate()
Add business logic before/after writebeforeCreate / afterCreate / beforeUpdate / afterUpdate hooksOverride controller methods
Add a custom action (non-CRUD)RecordFunctionType (RPC) in functions: or global_functionsA dedicated controller route
Build a list endpoint (custom query)RecordService::applyRequestFilters() or Builder macroManual pagination + response shaping
Return a JSON responseRecordApiResponseService static methodsresponse()->json() directly
Guard an endpoint by permissionpmsName on RecordTableType or RecordFunctionTypeRoute middleware can: or manual Gate::check()
Override permission names per actionpermissions: [...] on RecordTableTypeCustom authorization handler in config/record.php
Multi-tenant isolationhasTenantId: true on RecordTableTypeManual where('tenant_id', ...) in hooks
Soft-delete + restore + force-deletesoftDeletes: true on RecordTableTypeCustom delete logic
Hide sensitive columnscolumnHiddens: [...] in configmakeHidden() on model or manual unset
Protect columns from being writtencolumnWriteDisabled: [...] in configUnset fields in hooks or middleware
Add computed fields to read responsesattributes: [...] on RecordTableTypeManual post-processing in controller
Cast raw DB column values in responsescasting: [...] on RecordTableTypeManual type coercion in hooks or controller

When Asked to Create a New Module or Table ​

This is the exact scaffolding pattern. Generate only what is listed here.

What to generate ​

ArtifactGenerate?Notes
Database migrationYesStandard Laravel Schema::create
Eloquent ModelYes (minimal)Only for AuditableTrait — no scopes, no queries, no business logic
RecordTableType configYesIn app/Api/Tables/ (class-based) or inline in config/record.php
ControllerNoPackage registers all CRUD routes automatically
RouteNoPackage registers all routes automatically
Service classNoUse lifecycle hooks in RecordTableType instead
Form RequestNoUse createValidator / updateValidator in config
API Resource classNoPackage handles all response shaping

Step-by-step ​

1. Migration — standard Laravel:

php
// database/migrations/YYYY_MM_DD_create_products_table.php
Schema::create('products', function (Blueprint $table) {
    $table->id();
    $table->string('name');
    $table->decimal('price', 10, 2);
    $table->softDeletes();   // add if softDeletes: true in config
    $table->timestamps();
});

2. Model — minimal, no query logic:

php
// app/Models/Product.php
class Product extends Model
{
    use \Mylekha\RecordApi\Traits\AuditableTrait; // only if model-event audit is needed
    protected $table    = 'products';
    protected $fillable = ['name', 'price'];
}
// Do NOT add: query scopes, relationships, business logic, or accessors.
// All queries go through DB::table() inside the package pipeline, not Eloquent.

3. RecordTableType — class-based (preferred for any non-trivial table):

php
// app/Api/Tables/ProductTable.php
class ProductTable extends \Mylekha\RecordApi\Resources\RecordResource
{
    public function configure(): RecordTableType
    {
        return new RecordTableType(
            table:        'products',
            softDeletes:  true,
            hasTenantId:  true,
            columnHiddens:  ['cost_price'],
            columnIndexes:  ['name'],
            createValidator: fn($req, $id) => Validator::make($req->all(), [
                'name'  => 'required|string|max:255',
                'price' => 'required|numeric|min:0',
            ]),
            updateValidator: fn($req, $id) => Validator::make($req->all(), [
                'price' => 'sometimes|numeric|min:0',
            ]),
            relationships: [
                new RecordBelongsToType(
                    name: 'category', foreignTable: 'categories',
                    foreignKey: 'category_id', localKey: 'id',
                ),
            ],
        );
    }
}

4. Register in config/record.php:

php
'tables' => [
    'products' => \App\Api\Tables\ProductTable::class,
],

Done. All CRUD + bulk + upsert endpoints are live. No controller, no route.


When a Controller IS Required ​

Only create a controller for:

  • Custom reporting / aggregation not achievable through config
  • Global RPC functions (registered in global_functions)
  • Table RPC functions (registered in RecordTableType::functions)

Mandatory rules inside any controller or action class ​

Query data   → DB::table()                          — never Eloquent model
List result  → RecordService::applyRequestFilters() — for all list/search endpoints
Single row   → DB::table('{table}')->find($id)
Response     → RecordApiResponseService::{method}() — never response()->json()
Registration → RecordFunctionType in config          — never a standalone Route::
php
// List: query builder + applyRequestFilters
$result = RecordService::applyRequestFilters(
    request:        $request,
    tableOrBuilder: DB::table('orders')->where('status', 'paid'),
    isArray:        true,
    orderBy:        'created_at',
);
return RecordApiResponseService::successWrapped(
    data:    $result['data'],
    meta:    $result['meta'],
    headers: $result['headers'],
);

// Single record
$record = DB::table('products')->find($id);
if (! $record) throw new RecordNotFoundException();
return RecordApiResponseService::successWrapped(data: $record);

// Write
$id = DB::table('products')->insertGetId($data);
return RecordApiResponseService::created(DB::table('products')->find($id));

Powerful Features ​

1. Zero-code full API from config ​

One RecordTableType generates 12+ endpoints automatically:

GET    /api/v1/{table}                  list with filtering, sorting, pagination, aggregation
GET    /api/v1/{table}/{id}             get one
POST   /api/v1/{table}                  create
PUT    /api/v1/{table}/{id}             full update
PATCH  /api/v1/{table}/{id}             partial update
DELETE /api/v1/{table}/{id}             soft delete
POST   /api/v1/{table}/{id}/restore     restore soft-deleted
DELETE /api/v1/{table}/{id}/force       permanent delete
POST   /api/v1/{table}/upsert           upsert single
POST   /api/v1/{table}/bulk/create      bulk create
POST   /api/v1/{table}/bulk/update      bulk update
POST   /api/v1/{table}/bulk/delete      bulk delete
POST   /api/v1/{table}/bulk/upsert      bulk upsert

2. Nested relationship writes in one request ​

Create or update a parent and all its children in one POST/PUT/PATCH. No separate child endpoint calls needed.

json
POST /api/v1/orders
{
  "customer_id": 42,
  "items": [
    { "product_id": 10, "qty": 2, "price": 29.99 },
    { "id": 5, "qty": 3 },
    { "id": 99, "_delete": true }
  ],
  "tags": [1, { "id": 3, "pivot": { "order": 1 } }]
}

3. Advanced query on every list endpoint — zero code ​

Every GET /{table} supports out of the box:

?select=*,category(id,name),variants(*)   column selection + relationship embedding
?s=search term                             full-text search across indexed columns
?status=eq.active&price=gte.100           filter operators + grouped logic
?vendor_id=eq.27&or=(balance_due.gt.0,id.eq.5) grouped OR/AND expressions
?sortby=price&order=desc                   sorting
?page=2&per_page=25                        pagination
?limit=50                                  first rows, no paging
?aggregate=count,sum:price&group_by=status aggregation + group by

4. Query caching ​

With RECORD_API_CACHE_API=true, list and record queries are cached per table and invalidated on every write to that table (caching is off by default). Disable per table: disableCache: true. RPC functions control cache TTL via cacheTTL and invalidation via clearCacheTables.

Full filter/operator reference and compatibility notes:

5. Zero-code multi-tenancy ​

Set hasTenantId: true on any table. Every query is then automatically scoped to the X-Tenant-ID header value — reads, writes, deletes. No code change needed.

6. Auto-generated OpenAPI always in sync ​

GET /api/v1/docs/openapi.json reflects the live config: tables, columns, relationships, RPC functions, query schemas, response schemas. No manual API doc maintenance.

7. Lifecycle hooks as business logic layer ​

Hooks run at every stage of the request. Use them for:

  • Injecting computed fields before insert (beforeCreate)
  • Sending notifications after write (afterCreate, afterUpdate)
  • Blocking operations on business rules (beforeDelete)
  • Invalidating downstream caches (afterUpdate)

8. Comprehensive audit trail — automatic ​

Every CRUD operation is logged: who, what, when, before/after state, recap of changed fields. Accessible via GET /api/v1/audit/logs, field timeline, and stats endpoints. Override per record with customAuditLog. Disable per table with disableAuditLog: true.


Config File Layout ​

config/
  record.php              master config — table registry + global functions
  records/tables/         optional per-table class files (lazy-loaded)
  audit.php               audit logging settings
  record-api.php      auth guard, OpenAPI output path

config/record.php top-level keys:

php
return [
    'tables' => [
        'products'   => \App\Api\Tables\ProductTable::class,  // class-based (lazy)
        'categories' => new RecordTableType(...),              // inline
    ],
    'global_functions' => [
        'health-check' => \App\Api\Functions\HealthCheckFunction::class,
        'auth/login'   => \App\Api\Functions\Auth\LoginFunction::class,
    ],
];

RecordTableType — Full Property Reference ​

php
new RecordTableType(
    // Identity
    table:               'products',   // database table name (required)
    primaryKey:          'id',         // PK column, default: 'id'
    pmsName:             'products',   // Spatie permission prefix; auto-derived if omitted

    // Endpoint control
    canRead:             true,         // expose GET endpoints
    canCreate:           true,         // expose POST
    canUpdate:           true,         // expose PUT / PATCH
    canDelete:           true,         // expose DELETE / restore / force
    canUpsert:           true,         // expose POST /upsert and /bulk/upsert

    // Authentication — false skips the auth and permission check for that action group
    isAuthRead:          true,         // list / show
    isAuthWrite:         true,         // create / update / delete / upsert
    // Equivalent older form: public: new RecordTablePublic(read: true, write: false)

    // Features
    softDeletes:         true,         // enables soft-delete, restore, force-delete
    hasTenantId:         true,         // filter by the tenant header (needs record.enable_tenant_id)
    disableCache:        false,        // skip query caching for this table
    disableAuditLog:     false,        // skip audit trail for this table
    uniqueIds:           ['uuid'],     // server-generated UUIDv7 on every insert; never changed by updates
    overrideTimestamps:  false,        // true = keep a client-sent updated_at (created_at is always kept if sent)
    overrideUserstamps:  false,        // true = keep client-sent created_by / updated_by

    // Column control
    columnHiddens:       ['cost_price'],       // never returned in responses
    columnWriteDisabled: ['sku'],              // silently ignored on create/update
    columnIndexes:       ['name','description'], // included in full-text search

    // Computed attributes — lazy, only resolved when field is in ?select=
    attributes:          [
        'full_label' => [\App\Attributes\BrandAttribute::class, 'getFullLabel'],
        'logo_url'   => \App\Attributes\BrandAttribute::class . '@getLogoUrl',
        'is_premium' => fn($row, $table) => ($row->tier ?? null) === 'premium',
    ],

    // Column casts — top-level map mirroring Laravel's $casts on Eloquent models
    // Built-ins: int, integer, float, double, real, decimal, decimal:N, string,
    //            bool, boolean, array, json, object, date, datetime, timestamp
    // Custom callable receives ($value, $column, $row):
    //   Closure | [Class, 'method'] (static or instance) | 'Class@method' | 'ClassName'→get()
    // Only listed columns are cast; null values preserved; compositeFields columns skipped
    casting: [
        'price'    => 'float',
        'is_cloud' => [\App\Record\Casts\GlobalCasting::class, 'bool'],
        'metadata' => 'array',
        'status'   => fn($v) => strtoupper($v),
    ],
    columns: [...],  // no 'cast' key needed inside column entries

    // Per-table permission map — overrides pmsName-based auto-generated permissions per action.
    // Keys: 'read', 'create', 'update', 'delete', 'force_delete', 'restore'
    // Values: string | string[] (any one matching permission grants access)
    // Actions not listed fall back to standard pmsName + permission_separator mapping.
    // Note: 'force_delete' is independent — it does NOT fall back to 'delete'.
    permissions: [
        'read'         => 'view_list_item',
        'create'       => 'insert_new_item',
        'update'       => 'update_existing_item',
        'delete'       => 'remove_item',
        'force_delete' => 'permanently_remove_item',
        'restore'      => 'restore_item',
    ],

    // Composed config (detailed below)
    relationships:       [...],
    functions:           [...],
    createValidator:     fn(Request $request, $id): Validator => ...,   // closures are fine here
    updateValidator:     fn(Request $request, $id): Validator => ...,
    deleteValidator:     fn(Request $request, $id): Validator => ...,

    // Hooks are static class methods, never closures (see Lifecycle Triggers)
    beforeRead:          new RecordTableTriggerType(class: ProductHooks::class, functionName: 'beforeRead'),
    afterRead:           ...,
    beforeCreate:        new RecordTableTriggerType(class: ProductHooks::class, functionName: 'beforeCreate'),
    afterCreate:         ...,
    beforeUpdate:        ...,
    afterUpdate:         ...,
    beforeDelete:        ...,
    afterDelete:         ...,
    beforeRestore:       ...,
    afterRestore:        ...,
    customAuditLog:      [InvoiceAuditLogger::class, 'handle'],   // or 'Class@method'
);

Class-Based Table Config ​

php
// app/Api/Tables/ProductTable.php
class ProductTable extends \Mylekha\RecordApi\Resources\RecordResource
{
    public function configure(): RecordTableType
    {
        return new RecordTableType(table: 'products', ...);
    }
}

Class-based configs are lazy-loaded — only instantiated when that table is accessed.


Relationship Types ​

All types are in Mylekha\RecordApi\Types\Relationships\.

BelongsTo ​

php
new RecordBelongsToType(
    name: 'category',        // key used in ?select= and write payload
    foreignTable: 'categories',
    foreignKey: 'category_id',  // FK on THIS table
    localKey: 'id',
)

Write: set FK in root payload — { "category_id": 3 }.

HasMany ​

php
new RecordHasManyType(
    name: 'variants',
    foreignTable: 'product_variants',
    foreignKey: 'product_id',   // FK on CHILD table
    localKey: 'id',
)

HasOne ​

php
new RecordHasManyType(
    name: 'thumbnail', foreignTable: 'product_images',
    foreignKey: 'product_id', localKey: 'id',
    type: RecordRelationshipsEnum::HAS_ONE,
)

BelongsToMany ​

php
new RecordMetaBelongsToManyType(
    name: 'tags', foreignTable: 'tags',
    pivotTable: 'product_tags',
    foreignPivotKey: 'product_id',
    relatedPivotKey: 'tag_id',
    pivotColumns: ['order'],   // optional extra pivot columns
    withTimestamps: true,
)

HasManyThrough ​

php
new RecordHasManyThroughType(
    name: 'reviews', foreignTable: 'reviews',
    throughTable: 'order_items',
    firstKey: 'product_id',     // FK on through-table → THIS table
    secondKey: 'order_item_id', // FK on reviews → through-table
    localKey: 'id', secondLocalKey: 'id',
)

HasOneThrough ​

Same as HasManyThrough with type: RecordRelationshipsEnum::HAS_ONE_THROUGH.

Morph Relationships ​

php
// MorphMany
new RecordHasManyType(
    name: 'comments', foreignTable: 'comments',
    foreignKey: 'commentable_id', localKey: 'id',
    type: RecordRelationshipsEnum::MORPH_MANY,
    morphType: 'commentable_type',
    morphClass: 'App\\Models\\Product',
)
// MorphToMany
new RecordMetaBelongsToManyType(
    name: 'tags', foreignTable: 'tags', pivotTable: 'taggables',
    foreignPivotKey: 'taggable_id', relatedPivotKey: 'tag_id',
    type: RecordRelationshipsEnum::MORPH_TO_MANY,
    morphType: 'taggable_type', morphClass: 'App\\Models\\Product',
)

Spatie Permission ​

php
new RecordSpatiePermissionType(name: 'roles', type: 'roles', guardName: 'api')

Relationship Write Payload Summary ​

RelationshipPayload shapeRemove child
BelongsTo"fk_col": id in root—
HasMany"rel": [id, {id,...}, {...}]{id, "_delete": true}
HasOne"rel": {fields}{"_delete": true}
BelongsToMany"rel": [id, {id, pivot:{}}]{id, "_delete": true}
HasManyThrough"rel": [id, {id}]{id, "_delete": true}
MorphMany"rel": [{data}]{id, "_delete": true}
MorphToMany"rel": [id, {id}]{id, "_delete": true}
MorphTotype + id fields in root—
Spatie"rel": [id, name, {id}]{id, "_delete": true}

Validators ​

Run before create / update / delete. Return a Laravel Validator instance (closures are fine). Failure → error_code: 10004, HTTP 422. Bulk endpoints run the default schema validation and the before-hooks per row, but not createValidator / updateValidator.

Signature: fn(Request $request, $id): \Illuminate\Validation\Validator ($id is null on create)

php
createValidator: fn($request, $id) => Validator::make($request->all(), [
    'name'  => 'required|string|max:255',
    'sku'   => 'required|string|unique:products,sku',
    'price' => 'required|numeric|min:0',
]),
updateValidator: fn($request, $id) => Validator::make($request->all(), [
    'sku'   => "sometimes|string|unique:products,sku,{$id}",
    'price' => 'sometimes|numeric|min:0',
]),

Lifecycle Triggers ​

Hooks are static methods on a class, registered with RecordTableTriggerType (or an array of them, run in order). A closure is rejected with a TypeError.

php
use Mylekha\RecordApi\Types\RecordTableTriggerType;

beforeCreate: new RecordTableTriggerType(class: ProductHooks::class, functionName: 'beforeCreate'),

Every hook is called as ProductHooks::method(Request $request, string $table, array $context).

HookFires$context keys
beforeRead / afterReadlist and showtype (index or show), id (show), tenant column; after list also data, filters
beforeCreatebefore INSERTtenant column
beforeUpdatebefore UPDATEid, tenant column
beforeDelete / beforeRestorebefore DELETE / RESTOREid, tenant column, record (the current row)
afterCreate / afterUpdate / afterDelete / afterRestoreafter the writeid, payload or record, and the result flags (updated, deleted, …)

What a before hook may return:

ReturnEffect
nullcontinue unchanged
Requestcontinue with this request
arraymerged into the request, then continue
JsonResponsestop and send it (wrapped in the standard envelope if it is not already)

To reject a write, return a response. Do not throw or abort(): anything thrown from a hook becomes a generic 500.

php
class ProductHooks
{
    public static function beforeCreate(Request $request, string $table, array $context): ?array
    {
        return ['created_by' => auth()->id()];               // merged into the request
    }

    public static function beforeDelete(Request $request, string $table, array $context): ?JsonResponse
    {
        if ($context['record']->has_active_orders ?? false) {
            return RecordApiResponseService::errorWrapped('Cannot delete with active orders', 422);
        }

        return null;
    }

    public static function afterUpdate(Request $request, string $table, array $context): void
    {
        cache()->forget("product:{$context['id']}");
    }
}

Hooks for every table go in config('record.global_triggers'), keyed by hook name, with the same signature. A global before-hook runs before the table's own; a global after-hook runs after it.


RPC Functions — Config (RecordFunctionType) ​

php
new RecordFunctionType(
    httpMethod:       ['POST'],           // array — GET | POST | PUT | PATCH | DELETE
    class:            PublishProductAction::class,
    functionName:     'handle',           // method name to call on the class
    pmsName:          'products.publish', // Spatie permission; null = public
    disableCache:     true,
    cacheTTL:         3600,               // seconds; GET requests only
    clearCacheTables: ['products'],       // tables to invalidate after write
    description:      'Publish a product',
    querySchema:      [...],              // optional OpenAPI query param schema
    payloadSchema:    [...],              // optional OpenAPI request body schema
    responseSchema:   [...],             // optional OpenAPI response schema
)

Global function (class-based) ​

php
class LoginFunction extends \Mylekha\RecordApi\Resources\GlobalFunction
{
    public function configure(): RecordFunctionType
    {
        return new RecordFunctionType(
            httpMethod: ['POST'], class: LoginAction::class,
            functionName: 'handle', pmsName: null,
        );
    }
}

Register in config/record.php:

php
'global_functions' => ['auth/login' => LoginFunction::class]

Endpoint: POST /api/v1/rpc/auth/login

Table-scoped function ​

Register inside RecordTableType::functions:

php
functions: ['publish' => new RecordFunctionType(...)]

Endpoint: POST /api/v1/products/rpc/publish


RPC Action Class — Return Types & Response ​

The action method receives Request $request and can return either:

Option A — return array (framework wraps it automatically) ​

php
use Illuminate\Http\Request;
use Mylekha\RecordApi\Exceptions\RecordNotFoundException;
use Mylekha\RecordApi\Exceptions\RecordForbiddenException;
use Mylekha\RecordApi\Exceptions\RecordServerException;

class PublishProductAction
{
    public function handle(Request $request): array
    {
        $id = $request->route('id'); // available for table-scoped functions

        $product = Product::find($id);
        if (! $product) throw new RecordNotFoundException();

        $product->update(['status' => 'published']);

        // Returning array → auto-wrapped into standard success envelope
        return ['published' => true, 'id' => $product->id];
    }
}

Response produced:

json
{ "success": true, "error_code": 0, "data": { "published": true, "id": 5 }, "meta": {...} }

Option B — return JsonResponse directly (full control) ​

php
use Illuminate\Http\JsonResponse;
use Illuminate\Http\Request;
use Mylekha\RecordApi\Services\RecordApiResponseService;

class LoginAction
{
    public function handle(Request $request): JsonResponse
    {
        $credentials = $request->only('email', 'password');

        if (! $token = auth()->attempt($credentials)) {
            return RecordApiResponseService::unauthorized('Invalid credentials');
        }

        return RecordApiResponseService::successWrapped(
            data: ['token' => $token, 'type' => 'bearer'],
            meta: ['expires_in' => 3600],
        );
    }
}

RecordApiResponseService — Full Method Reference ​

All methods are static. Import: use Mylekha\RecordApi\Services\RecordApiResponseService;

Standard wrapped responses (match the API envelope) ​

php
// Success with data + optional meta
RecordApiResponseService::successWrapped(
    data:       $data,        // mixed
    meta:       [],           // array — merged into meta object
    status:     200,          // HTTP status code
    headers:    [],           // extra HTTP headers
    error_code: null,         // override error_code (default 0)
): JsonResponse

// Error with message + optional field errors
RecordApiResponseService::errorWrapped(
    message:    'Something failed',
    status:     500,
    errors:     [],           // ['field' => ['message']]
    error_code: null,
): JsonResponse

Shorthand responses ​

php
RecordApiResponseService::success($data)          // 200, error_code 0
RecordApiResponseService::created($data)          // 201, error_code 0
RecordApiResponseService::updated($data)          // 200, error_code 0
RecordApiResponseService::deleted($data)          // 200, error_code 0
RecordApiResponseService::notFound($message)      // 404, error_code 10009
RecordApiResponseService::unauthorized($message)  // 401, error_code 10002
RecordApiResponseService::forbidden($message)     // 403, error_code 10008
RecordApiResponseService::validationError($errors)// 422, error_code 10004
RecordApiResponseService::serverError($message)   // 500, error_code 10010

Data helpers ​

php
// Remove columns listed in columnHiddens for a table
RecordApiResponseService::removeHiddenFields($data, $table): mixed

// Remove deleted_at from response data
RecordApiResponseService::removeDeletedAtFields($data): mixed

Custom Controllers — Using Package Standards ​

When building a custom controller (not RPC), use the package's response and query tools to stay consistent with the standard API contract.

List / Get endpoint — use applyRequestFilters ​

RecordService::applyRequestFilters() gives your custom endpoint all standard query support: ?select, ?s, ?sortby, ?order, ?per_page, ?page, ?limit, filters, aggregation, relationships.

php
use Illuminate\Http\Request;
use Illuminate\Http\JsonResponse;
use Mylekha\RecordApi\Services\RecordService;
use Mylekha\RecordApi\Services\RecordApiResponseService;

class ReportController extends Controller
{
    public function index(Request $request): JsonResponse
    {
        $result = RecordService::applyRequestFilters(
            request:      $request,
            tableOrBuilder: DB::table('orders')->where('status', 'paid'), // Builder
            isArray:      true,
            orderBy:      'created_at',
        );
        // $result keys: data, meta, headers, filters, request

        return RecordApiResponseService::successWrapped(
            data:    $result['data'],
            meta:    $result['meta'],
            headers: $result['headers'],
        );
    }
}

Alternatively, pass a table name string or a RecordTableType (inherits column config):

php
// By table name — no column config applied
RecordService::applyRequestFilters($request, 'orders');

// By RecordTableType — columnHiddens and config are respected
RecordService::applyRequestFilters($request, $tableType);

// Via Builder macro (same underlying call)
DB::table('orders')
    ->where('status', 'paid')
    ->applyRequestFilters($request, isArray: true, orderBy: 'created_at');
// Returns same array: ['data', 'meta', 'headers', 'filters', 'request']

applyRequestFilters — full signature ​

php
RecordService::applyRequestFilters(
    request:        Request $request,
    tableOrBuilder: Builder|RecordTableType|string,
    tanentColumn:   ?string = '',   // tenant column name if filtering manually
    isArray:        bool    = true, // true = return array; false = return collection
    orderBy:        string  = 'id', // default sort column when ?sortby not provided
): array
// Returns: ['data', 'meta', 'headers', 'filters', 'request']

Single record endpoint ​

php
public function show(Request $request, int $id): JsonResponse
{
    $record = DB::table('products')->find($id);

    if (! $record) {
        return RecordApiResponseService::notFound('Product not found');
    }

    return RecordApiResponseService::successWrapped(data: $record);
}

Write endpoint ​

php
public function store(Request $request): JsonResponse
{
    $data = $request->validated();
    $id   = DB::table('products')->insertGetId($data);

    return RecordApiResponseService::created(
        DB::table('products')->find($id)
    );
}

Error handling pattern ​

php
use Mylekha\RecordApi\Exceptions\RecordNotFoundException;
use Mylekha\RecordApi\Exceptions\RecordForbiddenException;

// Throw package exceptions — they are caught and formatted automatically
throw new RecordNotFoundException();     // → 404, error_code 10009
throw new RecordForbiddenException();    // → 403, error_code 10008
throw new RecordServerException();       // → 500, error_code 10010

// Or return directly
return RecordApiResponseService::validationError(['email' => ['Required']]);
return RecordApiResponseService::unauthorized();
return RecordApiResponseService::forbidden();

Audit Logging ​

config/audit.php key settings:

php
'enabled'           => true,
'queue_enabled'     => true,
'log_relationships' => false,
'subject_fields'    => ['name', 'title', 'email'],
'entity_labels'     => ['products' => 'Product'],
'recap_max_fields'  => 5,

Per-table override in RecordTableType:

php
disableAuditLog: false,
customAuditLog: fn($event, $entityClass, $data, $tenantId, $ctx) => /* custom logic */,

Model-driven (outside CRUD API):

php
class Product extends Model { use \Mylekha\RecordApi\Traits\AuditableTrait; }

QueryHelpers Trait ​

For custom Eloquent controllers outside the CRUD API:

php
class Product extends Model { use \Mylekha\RecordApi\Traits\QueryHelpers; }

Product::query()->applyRequestFilters(request: $request, isArray: false, orderBy: 'id');
// Returns: Paginator | LazyCollection | Collection | Builder
// Extra params: ?join= ?select_join= ?lazy=true ?total_record=true

Exceptions ​

php
\Mylekha\RecordApi\Exceptions\RecordNotFoundException   // error_code 10009, HTTP 404
\Mylekha\RecordApi\Exceptions\RecordForbiddenException  // error_code 10008, HTTP 403
\Mylekha\RecordApi\Exceptions\RecordServerException     // error_code 10010, HTTP 500

Rate Limiting Groups ​

GroupApplies to
throttle:api-readsAll GET endpoints
throttle:api-writesPOST / PUT / PATCH / DELETE
throttle:api-functionsAll RPC function calls

Artisan Commands ​

record-api:record {table}              scaffold table config class
record-api:sync-record-columns         sync DB schema → column metadata
record-api:sync-record-columns --force --table=x
record-api:setup                       publish configs and migrations
record-api:validate --fix              validate installation, auto-repair
record-api:clean-audit-logs            purge expired audit records

Standard Response Shape ​

success:    bool
error_code: int      — 0 = ok; see the record-api-frontend skill for the full table
data:       object | array
meta:       { request_id, page, per_page, total }
message:    string   — errors only
errors:     { field: [string] }  — validation only

Note: key is error_code (snake_case); meta uses page not current_page.


Environment Variables ​

RECORD_API_AUTH_GUARD=api          # auth guard: api | web | sanctum | jwt …
RECORD_API_CACHE_API=false         # query caching (off by default)
RECORD_API_CACHE_API_TTL=3600
RECORD_API_DOCS_PRIVATE=false      # true = /api-docs needs a login
RECORD_API_DEBUG=false             # include debug details in error responses
RECORD_API_AUDIT_LOG_ENABLED=false # audit logging; see config/audit.php for the rest

Project Conventions ​

PathPurpose
config/record.phpTable and function registry
config/records/tables/Per-table class-based config files
config/audit.phpAudit logging settings
app/Api/Tables/RecordResource subclasses
app/Api/Functions/GlobalFunction subclasses
app/Actions/RPC action classes

Released under the MIT License.