Skip to content

Installation ​

Installation & Setup ​

Step 1: Install the Package ​

bash
composer require mylekha/record-api

Option B: Local Development (VCS Repository) ​

Add this to your project's composer.json:

json
{
    "repositories": [
        {
            "type": "vcs",
            "url": "https://github.com/MYLEKHATECH/laravel-record-api.git"
        }
    ],
    "require": {
        "mylekha/record-api": "*"
    }
}

Then run:

bash
composer update mylekha/record-api -W

Step 2: Publish/Generate Configuration Files ​

bash
php artisan record-api:setup

This command publishes package configs (tag: record-api-config) and creates missing app config files:

  • config/record.php - Database table configurations and relationships
  • config/audit.php - Audit logging settings
  • config/record-api.php - Package settings (auth guard, OpenAPI output)

To overwrite existing generated configs, run:

bash
php artisan record-api:setup --force

Step 3: Environment Configuration ​

Add these environment variables to your .env file:

env
# Audit logging
AUDIT_LOG_ENABLED=true
AUDIT_LOG_RETENTION_DAYS=365

# Which Laravel auth guard the package uses
RECORD_API_AUTH_GUARD=api

Step 4: Publish Migrations and Run Migrations ​

bash
php artisan vendor:publish --tag=record-api-migrations
php artisan migrate

Step 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):

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:

bash
# 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 --fix

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

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

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 default Gate::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.

php
// 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, show
  • create, update, delete, restore, force_delete, upsert
  • bulk, bulk_create, bulk_update, bulk_delete, bulk_upsert
  • table_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 (or record_context.tenant_id)
  • then fallback to tenant header (X-Tenant-ID by default)

Client middleware can set tenant before CRUD/controller logic:

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

php
$request->attributes->set('record_context', [
    'tenant_id' => $request->user()?->tenant_id,
]);

Use context inside trigger:

php
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):

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:

php
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 current Illuminate\Http\Request instance.
  • table – The logical table name used in the API (e.g. invoices).
  • operation – One of create, update, delete, or upsert.
  • record_context – The internal record context used by RecordService (includes id, payload, response, etc. depending on the operation).
bash
php artisan record-api:record customers

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

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

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

php
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=.

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

RequestBehaviour
GET /api/v1/brandsNo resolvers called
GET /api/v1/brands?select=id,nameNo resolvers called
GET /api/v1/brands?select=id,full_labelOnly full_label resolver fires
GET /api/v1/brands?select=*,logo_urllogo_url fires; * fetches all DB columns
GET /api/v1/brands/1?select=id,logo_urlWorks 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).

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

bash
# 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 ​

env
DB_CONNECTION=mysql
DB_HOST=127.0.0.1
DB_PORT=3306
DB_DATABASE=your_database
DB_USERNAME=your_username
DB_PASSWORD=your_password

Recommended MySQL Settings:

sql
-- 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 ​

env
DB_CONNECTION=pgsql
DB_HOST=127.0.0.1
DB_PORT=5432
DB_DATABASE=your_database
DB_USERNAME=your_username
DB_PASSWORD=your_password

SQLite Configuration ​

env
DB_CONNECTION=sqlite
DB_DATABASE=/absolute/path/to/database.sqlite

Note: SQLite is suitable for development but not recommended for production use with this package.

Released under the MIT License.