Installation
Installation & Setup
Step 1: Install the Package
Option A: Via Composer (Recommended for Production)
composer require mylekha/record-apiOption B: Local Development (VCS Repository)
Add this to your project's composer.json:
{
"repositories": [
{
"type": "vcs",
"url": "https://github.com/MYLEKHATECH/laravel-record-api.git"
}
],
"require": {
"mylekha/record-api": "*"
}
}Then run:
composer update mylekha/record-api -WStep 2: Publish/Generate Configuration Files
php artisan record-api:setupThis command publishes package configs (tag: record-api-config) and creates missing app config files:
config/record.php- Database table configurations and relationshipsconfig/audit.php- Audit logging settingsconfig/record-api.php- Package settings (auth guard, OpenAPI output)
To overwrite existing generated configs, run:
php artisan record-api:setup --forceStep 3: Environment Configuration
Add these environment variables to your .env file:
# Audit logging
AUDIT_LOG_ENABLED=true
AUDIT_LOG_RETENTION_DAYS=365
# Which Laravel auth guard the package uses
RECORD_API_AUTH_GUARD=apiStep 4: Publish Migrations and Run Migrations
php artisan vendor:publish --tag=record-api-migrations
php artisan migrateStep 5: Configure Rate Limiters
The package routes use throttle:api-reads, throttle:api-writes, and throttle:api-functions. Define them in your app (example in app/Providers/AppServiceProvider.php):
use Illuminate\Cache\RateLimiting\Limit;
use Illuminate\Http\Request;
use Illuminate\Support\Facades\RateLimiter;
public function boot(): void
{
RateLimiter::for('api-reads', function (Request $request): Limit {
$key = $request->user()?->getAuthIdentifier() ?? $request->ip();
return Limit::perMinute(200)->by((string) $key);
});
RateLimiter::for('api-writes', function (Request $request): Limit {
$key = $request->user()?->getAuthIdentifier() ?? $request->ip();
return Limit::perMinute(100)->by((string) $key);
});
RateLimiter::for('api-functions', function (Request $request): Limit {
$key = $request->user()?->getAuthIdentifier() ?? $request->ip();
return Limit::perMinute(100)->by((string) $key);
});
}Step 6: Validate Installation
Verify your installation is working correctly:
# Validate the complete setup
php artisan record-api:validate
# Run with verbose output for detailed information
php artisan record-api:validate --verbose
# Auto-fix common issues
php artisan record-api:validate --fixStep 7: Configure Database Tables
Edit config/record.php to configure your database tables for the dynamic API. See the examples directory for a complete configuration example:
use Mylekha\RecordApi\Types\RecordTableType;
use Mylekha\RecordApi\Types\RecordHasManyType;
use Mylekha\RecordApi\Types\RecordBelongsToType;
use Mylekha\RecordApi\Types\RecordTableTriggerType;
return [
'api_prefix' => 'api/v1',
'tables' => [
'users' => new RecordTableType(
pmsName: 'user',
table: 'users',
isAuthRead: true,
isAuthWrite: true,
relationships: [
'posts' => new RecordHasManyType(
table: 'posts',
foreignKey: 'user_id',
localKey: 'id',
),
],
softDeletes: false,
hasTenantId: false,
createValidator: function (\Illuminate\Http\Request $request, ?int $id = null): \Illuminate\Contracts\Validation\Validator {
return \Illuminate\Support\Facades\Validator::make($request->all(), [
'name' => 'required|string|max:255',
'email' => 'required|email',
]);
},
updateValidator: function (\Illuminate\Http\Request $request, ?int $id = null): \Illuminate\Contracts\Validation\Validator {
return \Illuminate\Support\Facades\Validator::make($request->all(), [
'name' => 'sometimes|required|string|max:255',
]);
},
deleteValidator: function (\Illuminate\Http\Request $request, ?int $id = null): \Illuminate\Contracts\Validation\Validator {
return \Illuminate\Support\Facades\Validator::make(['id' => $id], [
'id' => 'required|integer',
]);
},
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',
),
beforeRead: new RecordTableTriggerType(
class: \App\Record\Triggers\UserTriggers::class,
functionName: 'beforeRead',
),
afterRead: new RecordTableTriggerType(
class: \App\Record\Triggers\UserTriggers::class,
functionName: 'afterRead',
),
),
],
];The package routes are loaded automatically by Mylekha\RecordApi\RecordApiServiceProvider using this prefix. Record endpoints authorize per-table using isAuthRead / isAuthWrite and permissions; public remains as legacy compatibility and is derived from auth flags.
You can override permission evaluation by setting record.authorization in config/record.php:
'authorization' => \App\Security\RecordAuthorization::class,Handler contract:
- class-string: container-resolved and must expose
handle($user, $permission, $table, $action): bool - callable/closure: invoked as
fn($user, string $permission, string $table, string $action): bool null: fallback to defaultGate::forUser($user)->allows($permission)
Config-Driven Middleware Map (Client Use Case)
You can apply different middleware stacks per route action and per table without editing package routes.
// config/record.php
'middleware_map' => [
'default' => [
'*' => [],
'read' => [],
'write' => ['auth:sanctum'],
'function' => ['auth:sanctum'],
],
'tables' => [
// Public query routes
'customers' => [
'read' => [],
],
// Auth + subscription routes
'bills' => [
'write' => ['auth:sanctum', 'subscribed'],
'table_function' => ['auth:sanctum', 'subscribed'],
],
],
],Action names available in the middleware map:
list,showcreate,update,delete,restore,force_delete,upsertbulk,bulk_create,bulk_update,bulk_delete,bulk_upserttable_function,global_function- grouped keys:
read,write,function, and wildcard*
Request Context for Hooks and Custom Audit
Request context is built-in and always available for hooks and custom audit callbacks. Tenant resolution keeps backward compatibility:
- first from request attribute
resolved_tenant_id(orrecord_context.tenant_id) - then fallback to tenant header (
X-Tenant-IDby default)
Client middleware can set tenant before CRUD/controller logic:
public function handle($request, \Closure $next)
{
$request->attributes->set('resolved_tenant_id', $request->user()?->tenant_id);
return $next($request);
}Or set directly into request context:
$request->attributes->set('record_context', [
'tenant_id' => $request->user()?->tenant_id,
]);Use context inside trigger:
public static function beforeCreate(\Illuminate\Http\Request $request, string $table, array $context): array
{
$requestContext = $context['request_context'] ?? $request->attributes->get('record_context', []);
$tenantId = $requestContext['tenant_id'] ?? null;
$userId = $requestContext['user']['id'] ?? null;
$payload = $request->all();
$payload['tenant_id'] = $tenantId;
$payload['created_by'] = $userId;
$request->replace($payload);
return [$request, $table, $context];
}Alternatively, you can keep config/record.php focused on global options and define per-table configurations under config/records/tables using the Artisan helper:
Table-Level Custom Audit Logger
You can override the default audit logging behavior for a specific table by providing a customAuditLog callback on the RecordTableType configuration. When set, this callback is invoked instead of the built-in AuditLogService::insertAuditLog calls for that table.
Example table config (config/records/tables/invoices.php):
use Mylekha\RecordApi\Types\RecordTableType;
return new RecordTableType(
table: 'invoices',
pmsName: 'invoice',
isAuthRead: false,
isAuthWrite: false,
// String callback formats are supported...
// customAuditLog: \App\Http\Controllers\InvoiceAuditLogger::class . '@handle',
// ...and so is native PHP callable array syntax
customAuditLog: [\App\Http\Controllers\InvoiceAuditLogger::class, 'handle'],
);Example custom audit handler:
namespace App\Http\Controllers;
use Mylekha\RecordApi\Enums\AuditLogEventEnum;
use Mylekha\RecordApi\Services\AuditLogService;
class InvoiceAuditLogger
{
public function handle(
AuditLogEventEnum $event,
string $entityClass,
array $auditData,
mixed $tenantId,
array $context
): void {
// Optionally transform or enrich $auditData here
// Delegate to the core audit logic with your customized payload
AuditLogService::handleAuditDataEntry(
event: $event,
entityName: AuditLogService::getTableNameFromEntityType($entityClass),
entityType: AuditLogService::getTableNameFromEntityType($entityClass),
queryData: $auditData,
tenantId: $tenantId,
);
}
}The $context parameter contains useful runtime information you can use for more advanced scenarios:
request– The currentIlluminate\Http\Requestinstance.table– The logical table name used in the API (e.g.invoices).operation– One ofcreate,update,delete, orupsert.record_context– The internal record context used byRecordService(includesid,payload,response, etc. depending on the operation).
php artisan record-api:record customersThis generates config/records/tables/customers.php returning a RecordTableType for the customers table. After creating the file and the underlying database table, you can sync its columns definition from the DB schema:
php artisan record-api:sync-record-columns --forceServer-Generated Unique IDs
List columns in uniqueIds to have the engine generate them, e.g. an immutable UUID secondary key next to the integer primary key:
return new RecordTableType(
table: 'stores',
uniqueIds: ['uuid'],
);Every insert the engine performs into the table (create, bulk create, upserts that insert, nested relationship creates) sets each listed column to a fresh lowercase UUIDv7, ignoring any client-supplied value. Updates never change it: it is stripped from update payloads and left out of an upsert's update columns, so an upsert that matches an existing row keeps that row's value. Default validation adds no rules for these columns, and an upsert cannot use one in match_on (422). The column is still returned in responses like any other column. See docs/api-documentation.md for the full list of write paths.
Computed Attributes (Lazy Response Fields)
You can attach computed fields to any table's read responses using the attributes property on RecordTableType. Resolvers are lazy — they only execute when the field key appears explicitly in ?select=.
use Mylekha\RecordApi\Types\RecordTableType;
'brands' => new RecordTableType(
table: 'brands',
attributes: [
// [Class, method] — class resolved via Laravel container
'full_label' => [\App\Attributes\BrandAttribute::class, 'getFullLabel'],
// 'Class@method' string
'logo_url' => \App\Attributes\BrandAttribute::class . '@getLogoUrl',
// inline Closure
'is_premium' => fn($row, $table) => ($row->tier ?? null) === 'premium',
],
),Resolver receives ($row, $table) where $row is the raw DB row (stdClass) and $table is the table name string.
| Request | Behaviour |
|---|---|
GET /api/v1/brands | No resolvers called |
GET /api/v1/brands?select=id,name | No resolvers called |
GET /api/v1/brands?select=id,full_label | Only full_label resolver fires |
GET /api/v1/brands?select=*,logo_url | logo_url fires; * fetches all DB columns |
GET /api/v1/brands/1?select=id,logo_url | Works identically on single-record endpoint |
Attribute keys are automatically excluded from the SQL SELECT to prevent "Unknown column" database errors.
Column Casts
Use the top-level casting property on RecordTableType — a [column => cast] map, mirroring Laravel's $casts on Eloquent models. Only columns listed in casting are transformed; all others are untouched. null values are always preserved.
Flat keys target main-table columns. Dot-notation keys target columns inside eagerly-loaded relationships ('relation.column'), and work for both single-object relations (belongsTo/hasOne) and collection relations (hasMany/hasManyThrough).
use App\Record\Casts\GlobalCasting;
// config/record.php
'casting' => [
'is_active' => 'bool',
'amount' => 'decimal:2',
],
'products' => new RecordTableType(
table: 'products',
columns: [
'is_active' => ['type' => 'boolean'],
'quantity' => ['type' => 'bigint'],
'price' => ['type' => 'decimal(12,2)'],
],
casting: [
// flat main-table casts
'price' => 'float',
'quantity' => 'int',
'is_active' => 'bool',
'metadata' => 'array', // JSON string → array
'score' => 'decimal:4',
'created_at' => 'datetime', // ISO 8601
// static method cast
'is_cloud' => [GlobalCasting::class, 'bool'],
// inline Closure
'status' => fn($v) => strtoupper($v),
// dot-notation: cast columns inside a hasMany relation
'variants.price' => 'float',
'variants.qty' => 'int',
// dot-notation: cast a column inside a belongsTo relation
'brand.is_active' => 'bool',
'brand.founded_at' => 'date',
],
),Built-in cast strings mirror Laravel model cast names: int, integer, float, double, real, decimal, decimal:N, string, bool, boolean, array, json, object, date, datetime, timestamp.
Custom callable forms (all receive ($value, $column, $row)): Closure, [Class, 'method'] (static or instance), 'Class@method', 'ClassName' (calls ->get($value, $column, $row)).
Step 8: Test Your Installation
Test the dynamic API endpoints:
# Test basic API functionality
curl -X GET http://your-app.test/api/v1/users
# Test with authentication (if configured)
curl -X GET http://your-app.test/api/v1/users \
-H "Authorization: Bearer your-access-token"
# Test creating a record
curl -X POST http://your-app.test/api/v1/users \
-H "Content-Type: application/json" \
-d '{"name":"Test User","email":"test@example.com"}'Database-Specific Instructions
MySQL Configuration
DB_CONNECTION=mysql
DB_HOST=127.0.0.1
DB_PORT=3306
DB_DATABASE=your_database
DB_USERNAME=your_username
DB_PASSWORD=your_passwordRecommended MySQL Settings:
-- For better performance with large datasets
SET GLOBAL innodb_buffer_pool_size = 1G;
SET GLOBAL query_cache_size = 256M;
SET GLOBAL max_connections = 200;PostgreSQL Configuration
DB_CONNECTION=pgsql
DB_HOST=127.0.0.1
DB_PORT=5432
DB_DATABASE=your_database
DB_USERNAME=your_username
DB_PASSWORD=your_passwordSQLite Configuration
DB_CONNECTION=sqlite
DB_DATABASE=/absolute/path/to/database.sqliteNote: SQLite is suitable for development but not recommended for production use with this package.