rakhavirgiandi / laravel-apigator
Laravel package to auto-generate CRUD API (Controller, Model, Routes) from database tables
Package info
github.com/rakhavirgiandi/laravel-apigator
pkg:composer/rakhavirgiandi/laravel-apigator
Requires
- php: ^8.1
- illuminate/console: ^10.0|^11.0|^12.0
- illuminate/database: ^10.0|^11.0|^12.0
- illuminate/filesystem: ^10.0|^11.0|^12.0
- illuminate/support: ^10.0|^11.0|^12.0
This package is not auto-updated.
Last update: 2026-05-17 05:07:42 UTC
README
Auto-generate production-ready CRUD APIs — Controller, Model, and Routes — straight from your database tables. Comes with DataTables support, dynamic query filtering, custom schema joins, and full SQL injection protection.
Table of Contents
- Requirements
- Installation
- Quick Start
- Command Options
- Generated Files
- API Endpoints
- Dynamic Query Filtering
- Eager Loading Relations
- Custom Schema (mapSchema)
- DataTables Integration
- Validation Rules
- Configuration
- Security
- Advanced Usage
- File Structure
Requirements
- PHP >= 8.1
- Laravel >= 10.x
- MySQL, MariaDB, PostgreSQL, SQLite, or SQL Server
Installation
composer require rakhavirgiandi/laravel-apigator
Publish the config file (optional):
php artisan vendor:publish --tag=apigator-config
Quick Start
# Generate everything for a single table php artisan apigator:generate --table=products # Generate for all tables at once php artisan apigator:generate --table=all # Use a specific database connection php artisan apigator:generate --table=products --connection=mysql_secondary # Only generate specific parts php artisan apigator:generate --table=products --generate=model,controller # Custom output directories php artisan apigator:generate --table=products \ --controller-dir=Http/Controllers/API/V1 \ --model-dir=Models/API # Overwrite existing files php artisan apigator:generate --table=products --force
Command Options
| Option | Description | Default |
|---|---|---|
--table= |
Table name, or all to generate for every table |
(required) |
--connection= |
Database connection to use | App default connection |
--generate= |
Comma-separated list: model, controller, route |
All three |
--controller-dir= |
Controller directory (relative to app/) |
Http/Controllers/API |
--model-dir= |
Model directory (relative to app/) |
Models |
--force |
Overwrite existing files | false |
--connection
Point the generator to any connection defined in config/database.php:
php artisan apigator:generate --table=orders --connection=pgsql php artisan apigator:generate --table=all --connection=mysql_secondary
Connection resolution order: --connection → config('apigator.connection') → app default.
The generator validates the connection exists before running. An invalid connection name will exit early with a clear error.
--generate
Pick exactly what gets created — useful when you only need to regenerate one part:
# Model only php artisan apigator:generate --table=products --generate=model # Model + controller, skip routes php artisan apigator:generate --table=products --generate=model,controller # Routes only (model and controller already exist) php artisan apigator:generate --table=products --generate=route --force
Valid values: model, controller, route. Order doesn't matter — files are always generated in the correct sequence (model → controller → route).
Safety Checks
The generator automatically:
- Verifies the table exists in the database before generating anything
- Skips already-generated files (unless
--forceis passed) - Only checks file existence for the parts you're actually generating
- Skips system tables (
migrations,sessions,cache, etc.) when using--table=all
Generated Files
For a table named products, the command produces:
app/
Models/
Product.php ← Eloquent model with ApiModelTrait
Http/Controllers/API/
ProductController.php ← Thin controller, logic lives in the model
routes/
api.php ← 6 routes appended inside the Apigator marker block
Route Marker Block
All generated routes are written inside a clearly marked area in your route file. This makes them easy to find and prevents duplication:
// [APIGATOR_ENDPOINTS_START] // [APIGATOR_ENDPOINTS] products Route::get('/products', [ProductController::class, 'index']); Route::get('/products/{id}', [ProductController::class, 'show']); // ... // [APIGATOR_ENDPOINTS] orders Route::get('/orders', [OrderController::class, 'index']); // ... // [APIGATOR_ENDPOINTS_END]
If the marker block doesn't exist yet, it is created at the end of the file automatically. New tables are always inserted just before [APIGATOR_ENDPOINTS_END].
API Endpoints
For a table products (model Product, slug products):
| Method | URL | Description |
|---|---|---|
GET |
/products |
Paginated list |
GET |
/products/{id} |
Single record by ID |
POST |
/products |
Create a record |
PATCH |
/products/{id} |
Partial update |
DELETE |
/products/{id} |
Delete a record |
POST |
/products_datatable |
DataTables server-side endpoint |
GET /products — List
GET /products?page=1&per_page=15
{
"success": true,
"message": "Success",
"data": {
"meta": {
"current_page": 1,
"per_page": 15,
"total_pages": 4,
"total_items": 48
},
"data": [...]
}
}
GET /products/{id} — Single Record
GET /products/5
Search by a custom column (must be a real column):
GET /products/ABC-001?column=code
POST /products — Create
{
"name": "Widget A",
"price": 29.99,
"category_id": 3
}
Response 201:
{
"success": true,
"message": "Product created successfully.",
"data": { "id": 42, "name": "Widget A", ... }
}
PATCH /products/{id} — Partial Update
Only send the fields you want to change:
{ "price": 34.99 }
DELETE /products/{id} — Delete
If the model uses SoftDeletes, the record is soft-deleted instead of permanently removed.
Dynamic Query Filtering
All GET list endpoints and the DataTables endpoint support rich query filtering via URL parameters. Every filter is SQL-injection safe — column names are whitelisted against the actual schema and sanitized before use.
Basic Equality
GET /products?status=active
GET /products?category_id=3
Operators
Append [operator] to any column name:
| Parameter | SQL Equivalent |
|---|---|
?col[eq]=val |
col = val |
?col[neq]=val |
col != val |
?col[gt]=val |
col > val |
?col[gte]=val |
col >= val |
?col[lt]=val |
col < val |
?col[lte]=val |
col <= val |
?col[like]=val |
col LIKE %val% |
?col[starts]=val |
col LIKE val% |
?col[ends]=val |
col LIKE %val |
?col[in]=a,b,c |
col IN (a, b, c) |
?col[not_in]=a,b,c |
col NOT IN (a, b, c) |
?col[null]=1 |
col IS NULL |
?col[not_null]=1 |
col IS NOT NULL |
?col[between]=val1,val2 |
col BETWEEN val1 AND val2 |
?col[date_from]=2024-01-01 |
DATE(col) >= 2024-01-01 |
?col[date_to]=2024-12-31 |
DATE(col) <= 2024-12-31 |
Examples:
GET /products?price[between]=10,100
GET /products?created_at[date_from]=2024-01-01&created_at[date_to]=2024-12-31
GET /products?name[like]=widget
GET /products?status[in]=active,draft
GET /products?deleted_at[null]=1
OR Groups
Combine conditions with OR using the _or parameter:
GET /products?_or[0][status][eq]=active&_or[1][featured][eq]=1
→ WHERE (status = 'active') OR (featured = 1)
Mix OR and AND freely:
GET /products?category_id=3&_or[0][name][like]=widget&_or[1][name][like]=gadget
→ WHERE category_id = 3 AND ((name LIKE '%widget%') OR (name LIKE '%gadget%'))
Full-Text Search
GET /products?_search=blue widget
Searches across all string-type columns defined in mapSchema.
Sorting
GET /products?_sort=name # ASC
GET /products?_sort=-price # DESC (prefix with -)
GET /products?_sort=category_id,-price # multi-column
Pagination
GET /products?page=2&per_page=25
Eager Loading Relations
Any GET list or single-record endpoint supports eager loading Eloquent relations via the with query parameter. Relations are validated before loading — if the method doesn't exist or doesn't return an Eloquent Relation, it is silently skipped so typos never cause a fatal error.
Basic Usage
GET /products?with=category
GET /products?with=user,role
Nested Relations (dot notation)
GET /products?with=user.organization
GET /products?with=user.organization.country
GET /products?with=user.organization,role.permissions
Each segment of the dot-chain is validated against the corresponding model in sequence. If any segment is invalid, the whole chain is dropped — not just the bad segment.
Validation walk for ?with=user.organization.country:
user → method_exists(Product, 'user')? ✅ → resolves to User model
organization → method_exists(User, 'organization')? ✅ → resolves to Organization model
country → method_exists(Organization, 'country')? ✅ → chain valid ✅
Calling Directly from Code
Product::getList([ 'with' => 'user.organization,role', ]);
Supported Formats
| Format | Example |
|---|---|
| Single relation | ?with=user |
| Multiple relations | ?with=user,role |
| Nested (dot notation) | ?with=user.organization |
| Multiple nested | ?with=user.organization,role.permissions |
Custom Schema (mapSchema)
The generated model includes a mapSchema() method where you define custom SELECT columns, JOIN definitions, and static WHERE conditions.
Example: Products with Category join and inventory calculation
public static function mapSchema(array $params = [], array $user = []): array { $model = new self; $warehouseId = $params['warehouse_id'] ?? ''; return [ 'field' => [ 'id' => ['column' => $model->table.'.id', 'alias' => 'id', 'type' => 'int'], 'code' => ['column' => $model->table.'.code', 'alias' => 'code', 'type' => 'string'], 'name' => ['column' => $model->table.'.name', 'alias' => 'name', 'type' => 'string'], 'category_id' => ['column' => $model->table.'.category_id', 'alias' => 'category_id', 'type' => 'int'], 'category_name' => ['column' => 'cat.name', 'alias' => 'category_name', 'type' => 'string'], 'qty_on_hand' => [ 'column' => 'COALESCE(inv.qty, 0)', 'alias' => 'qty_on_hand', 'type' => 'float', 'is_raw' => true, // ← treated as a raw SQL expression ], 'has_variants' => [ 'column' => "CASE WHEN EXISTS (SELECT 1 FROM product_variants pv WHERE pv.product_id = {$model->table}.id) THEN 1 ELSE 0 END", 'alias' => 'has_variants', 'type' => 'bool', 'is_raw' => true, ], ], 'join' => [ [ 'table' => 'categories as cat', 'type' => 'left', 'on' => ['cat.id', '=', $model->table.'.category_id'], ], [ 'table' => DB::raw(" ( SELECT product_id, SUM( CASE WHEN type = 'IN' THEN qty ELSE -qty END ) AS qty FROM inventory_movements WHERE deleted_at IS NULL " . ($warehouseId ? "AND warehouse_id = {$warehouseId}" : "") . " GROUP BY product_id ) as inv "), 'type' => 'left', 'on' => ['inv.product_id', '=', $model->table.'.id'], ], ], 'where' => [ ['column' => $model->table.'.deleted_at', 'operator' => 'IS NULL', 'value' => null], ], ]; }
Field Definition Reference
| Key | Type | Description |
|---|---|---|
column |
string |
SQL column expression — table.column or a raw SQL expression |
alias |
string |
The key name returned in the response |
type |
string |
string, int, float, bool, date, datetime, json |
is_raw |
bool |
When true, the column value is used as raw SQL (not quoted) |
Dynamic Parameters in mapSchema
mapSchema receives the full request $params array, so you can drive query logic from any request parameter:
// ?warehouse_id=5 filters inventory per warehouse $warehouseId = $params['warehouse_id'] ?? '';
DataTables Integration
JavaScript Setup (DataTables 1.x / 2.x)
$('#table').DataTable({ processing: true, serverSide: true, ajax: { url: '/api/products_datatable', type: 'POST', headers: { 'X-CSRF-TOKEN': $('meta[name="csrf-token"]').attr('content'), 'Authorization': 'Bearer ' + token, }, }, columns: [ { data: 'id', name: 'id' }, { data: 'name', name: 'name' }, { data: 'price', name: 'price' }, { data: 'status', name: 'status', searchable: false }, ], });
Server Response Format
{
"draw": 1,
"recordsTotal": 500,
"recordsFiltered": 48,
"data": [...]
}
Features
- Global search (across all
searchable: truecolumns) - Per-column search and multi-column sort
- Pagination via
start/length - Works with
mapSchemajoins and computed columns - Compatible with DataTables 1.x and 2.x
Validation Rules
Validation rules are auto-generated from the database schema at generation time. They live in two static methods on the model:
Product::createRules() // Used by POST — required fields are marked required Product::updateRules() // Used by PATCH — all fields become optional
Type → Rule Mapping
| DB Type | Validation Rule |
|---|---|
int, int2, int4, int8, integer, bigint, smallint |
integer |
decimal, numeric, float, double |
numeric |
bool, boolean |
boolean |
date |
date |
datetime, timestamp |
date |
time |
date_format:H:i:s |
json, jsonb |
json |
| Everything else | string |
Non-nullable columns → required. Nullable columns → nullable.
You can customize the rules directly in the model at any time:
public static function createRules(): array { return [ 'name' => ['required', 'string', 'max:255', 'unique:products,name'], 'email' => ['required', 'email'], 'price' => ['required', 'numeric', 'min:0'], 'status' => ['required', 'string', 'in:active,inactive,draft'], 'image' => ['nullable', 'image', 'max:2048'], ]; }
Configuration
config/apigator.php:
return [ // Default database connection (falls back to app default if not set) 'connection' => null, // Default controller directory (relative to app/) 'controller_directory' => 'Http/Controllers/API', // Default model directory (relative to app/) 'model_directory' => 'Models', // Default API route delimiter 'route_delimiter' => '_', // Route file where generated routes are appended 'route_file' => 'routes/api.php', // Default items per page 'default_per_page' => 10, // Tables skipped when using --table=all 'exclude_tables' => [ 'migrations', 'password_resets', 'failed_jobs', 'personal_access_tokens', 'sessions', 'cache', 'cache_locks', 'jobs', 'job_batches', ], ];
Note:
route_middlewarehas been removed. Apply middleware directly in your route file using Laravel's standardRoute::middleware(...)wrapper around the Apigator marker block if needed.
Security
SQL Injection Protection
Every dynamic parameter passes through a multi-layer defense:
- Column whitelist — column names are validated against
Schema::getColumnListing(). Unknown columns are silently ignored. - Operator whitelist — only operators in the
OPERATORSconstant are accepted. Unknown operators are silently ignored. - Column sanitization — after whitelist check, column names are regex-sanitized to
[a-zA-Z0-9_.]only. - LIKE escaping —
%and_in user values are escaped before being used inLIKEclauses. - Parameter binding — all values go through PDO parameter binding; nothing is ever interpolated directly.
Input Validation
- All
POSTandPATCHdata is validated through Laravel'sValidatorbefore touching the database. - The
columnparameter inGET /{slug}/{id}?column=Xis validated againstSchema::getColumnListing().
Advanced Usage
Using ApiModelTrait in Existing Models
Add the trait to any existing model without regenerating:
use Virgiandi\ApiGenerator\Traits\ApiModelTrait; class Product extends Model { use ApiModelTrait; public static function mapSchema(array $params = [], array $user = []): array { // your schema definition } }
Calling Model Methods Directly
// Paginated list with filters $result = Product::getList([ 'status' => 'active', 'price[lte]' => 100, '_sort' => '-created_at', 'per_page' => 20, 'with' => 'category,user.organization', // eager load relations ]); // Single record $product = Product::getById(5); $product = Product::getById('PROD-001', ['column' => 'sku']); // Create / update / delete $product = Product::createRecord(['name' => 'New', 'price' => 9.99]); Product::updateRecord(5, ['price' => 14.99]); Product::deleteRecord(5); // DataTables $data = Product::getDatatable($request->all());
Extending the Controller
Generated controllers are intentionally thin. Add custom logic by extending:
class ProductController extends \App\Http\Controllers\API\ProductController { public function store(Request $request): JsonResponse { $request->merge(['created_by' => auth()->id()]); return parent::store($request); } }
File Structure
laravel-apigator/
├── composer.json
├── config/
│ └── apigator.php
└── src/
├── ApigatorServiceProvider.php
├── Commands/
│ └── GenerateApiCommand.php ← Artisan command
├── Generators/
│ ├── ModelGenerator.php ← Builds the Model PHP file
│ ├── ControllerGenerator.php ← Builds the Controller PHP file
│ └── RouteGenerator.php ← Appends routes inside the marker block
├── Support/
│ ├── DynamicQueryParser.php ← Parses URL params into Eloquent filters
│ ├── SchemaQueryBuilder.php ← Builds queries from mapSchema definitions
│ └── ValidationRuleBuilder.php ← Derives validation rules from column types
└── Traits/
├── ApiModelTrait.php ← Core CRUD + DataTables logic (on Model)
└── ApiControllerTrait.php ← JSON response helpers (on Controller)
License
MIT