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