laraditz/crudless

Minimal base API controller for Laravel - full CRUD with a single property declaration.

Maintainers

Package info

github.com/laraditz/crudless

pkg:composer/laraditz/crudless

Statistics

Installs: 6

Dependents: 0

Suggesters: 0

Stars: 0

Open Issues: 0

1.2.0 2026-04-11 02:20 UTC

This package is auto-updated.

Last update: 2026-04-11 02:20:30 UTC


README

Latest Version on Packagist Total Downloads License

The fastest way to build a Laravel API. Get full CRUD, auth (register, login, logout), or both working in minutes - use only what you need, no boilerplate, no repetition.

Requirements

  • PHP ^8.1
  • Laravel 10, 11, 12, or 13

Installation

composer require laraditz/crudless

Quick Start

Use whichever pieces you need - CRUD, auth, or both.

1. CRUD - extend BaseApiController:

use Laraditz\Crudless\BaseApiController;

class UserController extends BaseApiController {}
Route::apiResource('users', UserController::class);

index, show, store, update, destroy are live. The model is resolved automatically from the controller name (UserControllerApp\Models\User).

2. Auth - register routes in routes/api.php:

use Laraditz\Crudless\Facades\Crudless;

Crudless::authRoutes();

That's it. You now have:

Method URI Description
POST /auth/register Create account
POST /auth/login Get Sanctum token
POST /auth/logout Revoke token

Configuration Reference

Both BaseApiController and BaseAuthController are optional - use only what you need. Every property on each is optional too.

Model

protected ?string $model = null;

The Eloquent model this controller manages. Optional - when not declared, the model is guessed from the controller class name:

Controller Resolved model
UserController App\Models\UserApp\User
PostController App\Models\PostApp\Post

The first class that exists wins. Set $model explicitly when the model lives outside these namespaces:

protected string $model = \Domain\Blog\Models\Post::class;

API Resource

protected ?string $resource = null;

When set, all responses are wrapped through this resource class.

class UserController extends BaseApiController
{
    protected string $model     = User::class;
    protected ?string $resource = UserResource::class;
}

Applies to index, show, store, and update responses.

Eager Loading

protected array $with     = [];   // used by index()
protected array $withShow = [];   // used by show() - falls back to $with if empty
class PostController extends BaseApiController
{
    protected string $model    = Post::class;
    protected array $with      = ['author', 'category'];
    protected array $withShow  = ['author', 'category', 'tags', 'comments'];
}

$withShow lets show() load heavier relationships without affecting the list response.

Authorization

protected array $authorizedMethods = ['index', 'show', 'store', 'update', 'destroy'];

Lists which methods run a policy check. Laravel resolves the correct policy from the model automatically.

// Only write operations require authorization
class PostController extends BaseApiController
{
    protected string $model            = Post::class;
    protected array $authorizedMethods = ['store', 'update', 'destroy'];
}

// No authorization at all
class PublicPostController extends BaseApiController
{
    protected string $model            = Post::class;
    protected array $authorizedMethods = [];
}

Default is all five methods - secure by default.

Pagination

protected ?int $perPage    = null;   // null = no pagination
protected int $maxPerPage  = 100;    // cap on client-requested page size
class PostController extends BaseApiController
{
    protected string $model  = Post::class;
    protected ?int $perPage  = 15;
    protected int $maxPerPage = 50;
}

Client can override page size via ?per_page=25. The $maxPerPage cap prevents abuse.

When paginated, index() returns response()->api()->collection() which includes meta and links automatically.

When not paginated, returns the full collection via response()->api()->data()->success().

Validation

Three levels available - use whichever fits:

Level 1 - Form Request (recommended for complex rules):

protected ?string $storeRequest  = null;
protected ?string $updateRequest = null;
class PostController extends BaseApiController
{
    protected string $model          = Post::class;
    protected ?string $storeRequest  = StorePostRequest::class;
    protected ?string $updateRequest = UpdatePostRequest::class;
}

Level 2 - Ad-hoc rules (for simple cases):

class TagController extends BaseApiController
{
    protected string $model = Tag::class;

    protected function storeRules(): array
    {
        return ['name' => 'required|string|max:50|unique:tags'];
    }

    protected function updateRules(): array
    {
        return ['name' => 'required|string|max:50|unique:tags,name,' . request()->route('tag')];
    }
}

Level 3 - No validation:

Declare neither. Falls back to $request->all().

Priority: $storeRequeststoreRules()$request->all()

Custom Query

Override query() to chain additional constraints on index():

class PostController extends BaseApiController
{
    protected string $model = Post::class;

    protected function query(): Builder
    {
        return $this->resolveModel()::query()
            ->with($this->with)
            ->where('published', true)
            ->latest();
    }
}

The base query() is simply $this->resolveModel()::query()->with($this->with) - zero cost when not overridden.

Filtering

Integrates with laraditz/model-filter to enable dynamic query filtering on index() via request parameters.

Step 1 — add the Filterable trait to your model and declare filterable fields:

use Laraditz\ModelFilter\Filterable;

class Post extends Model
{
    use Filterable;

    protected array $filterable = ['title', 'status', 'author.name'];
}

Step 2 — filtering is applied automatically on index() with no extra configuration needed:

class PostController extends BaseApiController
{
    protected string $model = Post::class;
}

Clients can now filter via query parameters:

GET /posts?filters[status]=published
GET /posts?filters[title]=Laravel

For custom filter logic, generate a filter class and point $filter to it:

php artisan make:filter PostFilter
class PostController extends BaseApiController
{
    protected string $model  = Post::class;
    protected ?string $filter = PostFilter::class;
}

Lifecycle Hooks

Override any hook to add behaviour without replacing the entire method.

Before hooks run before the main action. Throw an exception (e.g. abort(403)) to stop execution.

After hooks receive the result and must return it - return a modified value to change what is sent in the response.

Hook Signature When it runs
beforeIndex (): void after auth, before query
afterIndex (mixed $data): mixed after query, before response
beforeShow (mixed $record): void after auth, before response
afterShow (mixed $record): mixed after beforeShow, before response
beforeStore (array $data): void after auth + validation, before create
afterStore (mixed $record): mixed after create, before response
beforeUpdate (mixed $record, array $data): void after auth + validation, before update
afterUpdate (mixed $record): mixed after update, before response
beforeDestroy (mixed $record): void after auth, before delete
afterDestroy (): void after delete, before response
class OrderController extends BaseApiController
{
    protected string $model = Order::class;

    // Send a notification after an order is created
    protected function afterStore(mixed $record): mixed
    {
        $record->notify(new OrderCreatedNotification());

        return $record;
    }

    // Prevent deletion of completed orders
    protected function beforeDestroy(mixed $record): void
    {
        abort_if($record->status === 'completed', 403, 'Completed orders cannot be deleted.');
    }
}

Response Map

All responses go through raditzfarhan/laravel-api-response via the response()->api() macro.

Method Response
index (paginated) response()->api()->collection($data) - includes meta and links
index (full list) response()->api()->data($data)->success()
show response()->api()->data($record)->success()
store response()->api()->created($record) - HTTP 201
update response()->api()->data($record)->success()
destroy response()->api()->httpCode(204)->success()

Full Example

A fully configured controller:

class PostController extends BaseApiController
{
    protected string $model            = Post::class;
    protected ?string $resource        = PostResource::class;

    protected array $with              = ['author', 'category'];
    protected array $withShow          = ['author', 'category', 'tags', 'comments'];

    protected array $authorizedMethods = ['store', 'update', 'destroy'];

    protected ?int $perPage            = 15;
    protected int $maxPerPage          = 50;

    protected ?string $storeRequest    = StorePostRequest::class;
    protected ?string $updateRequest   = UpdatePostRequest::class;

    protected ?string $filter          = PostFilter::class;

    protected function query(): Builder
    {
        return $this->resolveModel()::query()
            ->with($this->with)
            ->where('published', true)
            ->latest();
    }
}

A minimal one - model resolved automatically from the class name:

class TagController extends BaseApiController {}

Extending Individual Methods

For most customisation, prefer lifecycle hooks - they keep the method intact and compose cleanly. When you need full control, any CRUD method can be overridden:

class OrderController extends BaseApiController
{
    protected string $model = Order::class;

    public function store(Request $request)
    {
        $this->authorizeAction('create', $this->resolveModel());

        $data   = $this->resolveStoreData($request);
        $record = $this->resolveModel()::create($data);

        // full custom flow when hooks are not enough
        event(new OrderPlaced($record));

        return response()->api()->created($this->transform($record));
    }
}

Internal helpers authorizeAction(), resolveStoreData(), resolveUpdateData(), resolveModel(), and transform() are all protected and available in child classes.

Auth

BaseAuthController provides register, login, and logout via Laravel Sanctum. Skip this entirely if you have your own auth solution.

Step 1 - ensure HasApiTokens is on your User model:

use Laravel\Sanctum\HasApiTokens;

class User extends Authenticatable
{
    use HasApiTokens;
}

Step 2 - register routes in routes/api.php:

use Laraditz\Crudless\Facades\Crudless;

Crudless::authRoutes();

This registers:

Method URI Auth
POST /auth/register No
POST /auth/login No
POST /auth/logout Bearer token

Custom prefix or controller:

// Custom prefix only
Crudless::authRoutes('admin');

// Custom prefix + custom controller
Crudless::authRoutes('vendor', VendorAuthController::class);

Disable specific routes with $except:

// Register and login only — no register route
Crudless::authRoutes(except: ['register']);

// Login only
Crudless::authRoutes(except: ['register', 'logout']);

// Works with custom prefix and controller too
Crudless::authRoutes('admin', AdminAuthController::class, except: ['register']);

Available values: 'register', 'login', 'logout'.

Configuration properties:

protected string  $userModel       = \App\Models\User::class;
protected ?string $registerRequest = null;
protected ?string $loginRequest    = null;

Overridable rule methods:

class AdminAuthController extends BaseAuthController
{
    protected string $userModel = Admin::class;

    protected function registerRules(): array
    {
        return [
            ...parent::registerRules(),
            'department' => 'required|string',
        ];
    }
}

Lifecycle hooks:

Hook Signature When it runs
beforeRegister (array $data): void after validation, before creation
afterRegister (mixed $user): mixed after creation, before response
beforeLogin (array $data): void after validation, before credential check
afterLogin (mixed $user, string $token): mixed after token issued - return value is the response body
beforeLogout (mixed $user): void before token revocation
afterLogout (): void after revocation, before response
class AdminAuthController extends BaseAuthController
{
    protected function afterLogin(mixed $user, string $token): mixed
    {
        return ['token' => $token, 'role' => $user->role];
    }

    protected function beforeRegister(array $data): void
    {
        abort_if(!str_ends_with($data['email'], '@company.com'), 403, 'Company email required.');
    }
}

Dependencies

Changelog

Please see CHANGELOG for more information about recent changes.

Security

If you discover any security vulnerabilities, please email raditzfarhan@gmail.com instead of using the issue tracker. All security vulnerabilities will be promptly addressed.

Credits

License

The MIT License (MIT). Please see License File for more information.