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
- Tables are declared as
RecordTableTypeobjects inconfig/record.php. - The service provider reads this config and registers all routes automatically.
- HTTP requests flow through
CoreRecordController→RecordService→ hook callbacks. - All responses are wrapped by
RecordApiResponseServiceinto 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 automaticThe goal: adding a new API resource means adding one RecordTableType — never a controller, a route, or a model.
Architecture Rules — Follow These Always
| Task | Use this | Never do this |
|---|---|---|
| Expose a DB table as API | RecordTableType in config/record.php | Manual controller + route |
| Validate create / update / delete | createValidator / updateValidator / deleteValidator in config | Form Request, middleware, or manual $request->validate() |
| Add business logic before/after write | beforeCreate / afterCreate / beforeUpdate / afterUpdate hooks | Override controller methods |
| Add a custom action (non-CRUD) | RecordFunctionType (RPC) in functions: or global_functions | A dedicated controller route |
| Build a list endpoint (custom query) | RecordService::applyRequestFilters() or Builder macro | Manual pagination + response shaping |
| Return a JSON response | RecordApiResponseService static methods | response()->json() directly |
| Guard an endpoint by permission | pmsName on RecordTableType or RecordFunctionType | Route middleware can: or manual Gate::check() |
| Override permission names per action | permissions: [...] on RecordTableType | Custom authorization handler in config/record.php |
| Multi-tenant isolation | hasTenantId: true on RecordTableType | Manual where('tenant_id', ...) in hooks |
| Soft-delete + restore + force-delete | softDeletes: true on RecordTableType | Custom delete logic |
| Hide sensitive columns | columnHiddens: [...] in config | makeHidden() on model or manual unset |
| Protect columns from being written | columnWriteDisabled: [...] in config | Unset fields in hooks or middleware |
| Add computed fields to read responses | attributes: [...] on RecordTableType | Manual post-processing in controller |
| Cast raw DB column values in responses | casting: [...] on RecordTableType | Manual 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
| Artifact | Generate? | Notes |
|---|---|---|
| Database migration | Yes | Standard Laravel Schema::create |
| Eloquent Model | Yes (minimal) | Only for AuditableTrait — no scopes, no queries, no business logic |
RecordTableType config | Yes | In app/Api/Tables/ (class-based) or inline in config/record.php |
| Controller | No | Package registers all CRUD routes automatically |
| Route | No | Package registers all routes automatically |
| Service class | No | Use lifecycle hooks in RecordTableType instead |
| Form Request | No | Use createValidator / updateValidator in config |
| API Resource class | No | Package handles all response shaping |
Step-by-step
1. Migration — standard Laravel:
// 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:
// 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):
// 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:
'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::// 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 upsert2. 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.
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 by4. 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 pathconfig/record.php top-level keys:
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
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
// 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
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
new RecordHasManyType(
name: 'variants',
foreignTable: 'product_variants',
foreignKey: 'product_id', // FK on CHILD table
localKey: 'id',
)HasOne
new RecordHasManyType(
name: 'thumbnail', foreignTable: 'product_images',
foreignKey: 'product_id', localKey: 'id',
type: RecordRelationshipsEnum::HAS_ONE,
)BelongsToMany
new RecordMetaBelongsToManyType(
name: 'tags', foreignTable: 'tags',
pivotTable: 'product_tags',
foreignPivotKey: 'product_id',
relatedPivotKey: 'tag_id',
pivotColumns: ['order'], // optional extra pivot columns
withTimestamps: true,
)HasManyThrough
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
// 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
new RecordSpatiePermissionType(name: 'roles', type: 'roles', guardName: 'api')Relationship Write Payload Summary
| Relationship | Payload shape | Remove 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} |
| MorphTo | type + 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)
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.
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).
| Hook | Fires | $context keys |
|---|---|---|
beforeRead / afterRead | list and show | type (index or show), id (show), tenant column; after list also data, filters |
beforeCreate | before INSERT | tenant column |
beforeUpdate | before UPDATE | id, tenant column |
beforeDelete / beforeRestore | before DELETE / RESTORE | id, tenant column, record (the current row) |
afterCreate / afterUpdate / afterDelete / afterRestore | after the write | id, payload or record, and the result flags (updated, deleted, …) |
What a before hook may return:
| Return | Effect |
|---|---|
null | continue unchanged |
Request | continue with this request |
array | merged into the request, then continue |
JsonResponse | stop 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.
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)
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)
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:
'global_functions' => ['auth/login' => LoginFunction::class]Endpoint: POST /api/v1/rpc/auth/login
Table-scoped function
Register inside RecordTableType::functions:
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)
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:
{ "success": true, "error_code": 0, "data": { "published": true, "id": 5 }, "meta": {...} }Option B — return JsonResponse directly (full control)
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)
// 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,
): JsonResponseShorthand responses
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 10010Data helpers
// Remove columns listed in columnHiddens for a table
RecordApiResponseService::removeHiddenFields($data, $table): mixed
// Remove deleted_at from response data
RecordApiResponseService::removeDeletedAtFields($data): mixedCustom 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.
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):
// 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
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
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
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
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:
'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:
disableAuditLog: false,
customAuditLog: fn($event, $entityClass, $data, $tenantId, $ctx) => /* custom logic */,Model-driven (outside CRUD API):
class Product extends Model { use \Mylekha\RecordApi\Traits\AuditableTrait; }QueryHelpers Trait
For custom Eloquent controllers outside the CRUD API:
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=trueExceptions
\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 500Rate Limiting Groups
| Group | Applies to |
|---|---|
throttle:api-reads | All GET endpoints |
throttle:api-writes | POST / PUT / PATCH / DELETE |
throttle:api-functions | All 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 recordsStandard 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 onlyNote: 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 restProject Conventions
| Path | Purpose |
|---|---|
config/record.php | Table and function registry |
config/records/tables/ | Per-table class-based config files |
config/audit.php | Audit logging settings |
app/Api/Tables/ | RecordResource subclasses |
app/Api/Functions/ | GlobalFunction subclasses |
app/Actions/ | RPC action classes |