Version 1.5.0 introduced the make:crud Artisan command, a full API CRUD scaffold generator. It offers a code generation approach alongside the abstract CrudController base class, giving you production-ready Model, Controller, and Migration files in a single command, fully customisable through publishable stubs.
php artisan make:crud {name} [options]
Run the command with your model name and a set of options to instantly scaffold a complete API resource.
| Option | Type | Description |
|---|---|---|
name |
Argument | Model name in any case — e.g. Post, blog_post, UserProfile |
--fields |
String | Comma-separated field definitions: column:type:validation |
--belongs-to |
Multiple | BelongsTo relationship — e.g. --belongs-to=User |
--has-many |
Multiple | HasMany relationship — e.g. --has-many=Comment |
--has-one |
Multiple | HasOne relationship — e.g. --has-one=Profile |
--belongs-to-many |
Multiple | BelongsToMany relationship — e.g. --belongs-to-many=Tag |
--soft-deletes |
Flag | Adds SoftDeletes trait to model and softDeletes() column to migration |
--searchable |
String | Comma-separated fields to enable ?search= on in the index endpoint |
--sortable |
String | Comma-separated fields to allow ?sort_by= on. Defaults to id, the declared --fields, and created_at |
--per-page |
Integer | Default pagination size (default: 15) |
--max-per-page |
Integer | Largest page size a request may ask for (default: 100) |
--register-routes |
Flag | Automatically appends Route::apiResource(...) to routes/api.php |
--migrate |
Flag | Runs php artisan migrate immediately after generating the migration |
--force |
Flag | Overwrites existing model and controller (migration is never duplicated) |
Fields are defined as a comma-separated string using the format column:type:validation:
--fields="title:string:required|max:255,body:text:nullable,price:decimal:required|min:0"
| Input Type | Migration Column | Model Cast |
|---|---|---|
string, varchar |
string() |
— |
text, longtext, mediumtext |
text() / longText() / mediumText() |
— |
integer, int |
integer() |
integer |
biginteger, bigint |
bigInteger() |
integer |
tinyinteger, smallinteger |
tinyInteger() / smallInteger() |
integer |
float |
float() |
float |
double |
double() |
float |
decimal |
decimal(10, 2) |
decimal:2 |
boolean, bool |
boolean()->default(false) |
boolean |
date |
date() |
date |
datetime |
dateTime() |
datetime |
timestamp |
timestamp() |
datetime |
json |
json() |
array |
uuid |
uuid() |
— |
enum |
enum([], []) |
— |
The nullable validation rule automatically appends ->nullable() to the migration column.
--fields->nullable() applied when field rule contains nullable->unique() applied when field rule contains standalone uniqueforeignId('relation_id')->constrained('table')->cascadeOnDelete() for every --belongs-to$table->softDeletes() when --soft-deletes is set$table->id() and $table->timestamps()namespace, class, and extends Model$fillable populated from --fields plus all foreign key columns from --belongs-to$casts automatically populated for typed fields (boolean, decimal, date, json, etc.)use SoftDeletes trait and import when --soft-deletes is setpublic function user(): \Illuminate\Database\Eloquent\Relations\BelongsTo
public function comments(): \Illuminate\Database\Eloquent\Relations\HasMany
public function profile(): \Illuminate\Database\Eloquent\Relations\HasOne
public function tags(): \Illuminate\Database\Eloquent\Relations\BelongsToMany
apiResource controller with index, show, store, update, destroyshow, update, and destroystore returns HTTP 201data + meta (current_page, last_page, per_page, total)--searchable fields using ?search=term?sort_by=field&sort_direction=asc, restricted to an allow-list?per_page=N (default from --per-page), clamped by --max-per-page--fields and --belongs-to foreign keysunique rules automatically modified on update to ignore the current record's IDAs of v1.5.3 the generated index() carries an explicit allow-list, so ?sort_by is honoured only for columns you permit:
$sortable = ['id', 'title', 'body', 'created_at'];
if ($request->filled('sort_by') && in_array($request->input('sort_by'), $sortable, true)) {
$direction = strtolower($request->input('sort_direction', 'asc')) === 'desc' ? 'desc' : 'asc';
$query->orderBy($request->input('sort_by'), $direction);
}
A sort_by outside the list is ignored rather than rejected, and sort_direction is normalised to asc or desc. This keeps a caller from ordering by a column they should not be able to observe, such as a password hash, and inferring its value from the row order.
The list defaults to id, your declared --fields, and created_at, with credential-looking columns such as password, api_token, and remember_token left out. Set it yourself with --sortable:
php artisan make:crud Post --fields="title:string,body:text" --sortable="title,created_at"
Because the array is written into the method rather than stored as a class property, you can edit it directly in the generated controller at any time.
Controllers generated before v1.5.3 are unaffected, since the fix applies at generation time. Add an allow-list to their index() methods, or regenerate them with --force.
For every exists: and unique: rule the generated controller includes a human-readable message:
$request->validate([
'user_id' => 'required|integer|exists:users,id',
'slug' => 'required|unique:posts',
], [
'user_id.exists' => 'The selected user does not exist in the users table.',
'user_id.required' => 'The user field is required.',
'slug.unique' => 'This slug has already been taken.',
]);
On update, unique rules are automatically adjusted so the current record is excluded from the uniqueness check:
// store
'slug' => 'required|unique:posts'
// update (auto-generated)
'slug' => 'required|unique:posts,slug,' . $post->id
| Context | Generated Rule |
|---|---|
store |
required\|integer\|exists:table,id |
update |
sometimes\|integer\|exists:table,id |
After generation, the command checks the database for every related table. If any are missing it tells you exactly which ones and how to fix them — at generation time, not at runtime:
WARN The following related tables do not exist yet.
Eager loading and FK validation will fail until their migrations are run:
Comment → table comments not found
Run: php artisan make:model Comment -m
Tag → table tags not found
Run: php artisan make:model Tag -m
All three generated files are driven by publishable stubs. Users can override them to match their own code style without touching the package.
| Stub File | Controls |
|---|---|
stubs/crud.migration.stub |
Migration structure |
stubs/crud.model.stub |
Model structure |
stubs/crud.controller.stub |
Controller structure |
php artisan vendor:publish --tag=lara-util-x-stubs
Stubs are published to stubs/vendor/lara-util-x/ and are automatically picked up by the command. The package defaults are used as a fallback if no published stubs are found.
| File | Without --force |
With --force |
|---|---|---|
| Migration | Skipped if any migration for the table exists | Still skipped — migrations are never duplicated |
| Model | Skipped with warning | Overwritten |
| Controller | Skipped with warning | Overwritten |
php artisan make:crud Post \
--fields="title:string:required|max:255,slug:string:required|unique:posts,body:text:required,excerpt:string:nullable,price:decimal:nullable|min:0,is_published:boolean:nullable,published_at:datetime:nullable" \
--belongs-to=User \
--has-many=Comment \
--belongs-to-many=Tag \
--soft-deletes \
--searchable="title,body,excerpt" \
--per-page=10 \
--register-routes \
--migrate \
--force
This single command produces:
user_id foreign key, soft deletes, and timestamps$fillable, $casts, SoftDeletes, and 3 relationship methodsRoute::apiResource('posts', PostController::class) entry appended to routes/api.phpThe abstract CrudController base class is retained for manual use. Extend it when you would rather configure a controller than generate one:
use LaraUtilX\Http\Controllers\CrudController;
class PostController extends CrudController
{
protected array $validationRules = ['title' => 'required|string'];
protected array $searchableFields = ['title', 'body'];
protected array $sortableFields = ['title', 'created_at'];
protected array $relationships = ['author'];
protected int $perPage = 15;
public function __construct(Post $post)
{
parent::__construct($post);
}
}
It exposes getAllRecords, getRecordById, storeRecord, updateRecord, and deleteRecord.
?sort_by is honoured only for columns listed in $sortableFields. A value outside that list is ignored rather than rejected, and when the list is empty no sorting is applied at all. This keeps a caller from ordering results by a column they should not be able to observe, such as a password hash, and inferring its value from the row order.
?sort_direction accepts asc or desc; anything else falls back to asc.
| Change | Detail |
|---|---|
| Sorting restricted | sort_by now requires the column to be declared in $sortableFields. Previously any column was accepted. |
| Delete status code | deleteRecord() returns HTTP 200 with a message body. It previously returned 204, which is not permitted to carry a body. |
If you relied on sorting by an arbitrary column, add those columns to $sortableFields. If a client special-cased the 204 response, update it to expect 200.
?per_page is clamped so a caller cannot request the entire table in one response. The generated controller uses --max-per-page, and the abstract CrudController uses $maxPerPage:
class PostController extends CrudController
{
protected int $perPage = 15; // used when no ?per_page is given
protected int $maxPerPage = 100; // ceiling for whatever is requested
}
A per_page above the ceiling is reduced to it; a value below 1 falls back to the default.
Added in v1.5.4. Both the base class and generated controllers previously passed
?per_pagestraight topaginate(), so?per_page=1000000returned every row.
CrudController rewrites unique: rules on update so the record being edited does not collide with itself. As of v1.5.4 the rule segment is rebuilt rather than having the id appended to the whole string, which means it works when unique: is not the last rule and when it does not name its column:
required|unique:posts,slug|max:20 -> required|unique:posts,slug,7|max:20
unique:posts -> unique:posts,slug,7