devvelvet/laraattr

Build Laravel APIs by adding PHP 8 attributes to your models.

Maintainers

Package info

github.com/devvelvet/laraattr

pkg:composer/devvelvet/laraattr

Statistics

Installs: 2

Dependents: 0

Suggesters: 0

Stars: 0

Open Issues: 0

v0.2.0 2026-04-12 02:32 UTC

This package is auto-updated.

Last update: 2026-05-12 02:50:26 UTC


README

Build Laravel APIs by adding PHP 8 attributes to your models.

LaraAttr replaces the boilerplate Laravel API development requires (controllers, form requests, resources, route registration, …) with PHP 8 attribute declarations on your models. Inspired by Java Spring Boot's annotation-driven design, but built around Laravel's "elegance" and "developer happiness" philosophy.

#[ApiResource(prefix: '/api/users', name: 'users', resource: UserResource::class)]
#[Middleware('auth:sanctum', only: ['store', 'update', 'destroy'])]
#[Middleware('throttle:60,1')]
#[Schema(UserSchema::class)]
#[With(['profile'])]
#[Searchable(['name', 'email'])]
#[Sortable(['created_at', 'name'])]
#[Paginate(perPage: 20)]
#[Cache(ttl: 60)]
class User extends Model {}

That single block above auto-generates all of the following:

  • GET /api/users (search + sort + pagination + response caching + profile eager-loaded)
  • GET /api/users/{id} (profile eager-loaded)
  • POST /api/users (with auto validation)
  • PUT/PATCH /api/users/{id} (with auto validation + unique self-exclusion via {id})
  • DELETE /api/users/{id}
  • auth:sanctum on write endpoints, throttle:60,1 on all endpoints
  • All responses wrapped through UserResource (JsonResource)
  • An OpenAPI 3.0 spec via php artisan laraattr:docs

Table of contents

Installation

composer require devvelvet/laraattr

The service provider is registered automatically via Laravel's package auto-discovery — no manual registration step required.

To publish the config file:

php artisan vendor:publish --tag=laraattr-config

This creates config/laraattr.php.

Quick start

1. Create a Schema class

app/Schemas/UserSchema.php:

<?php

namespace App\Schemas;

use LaraAttr\Attributes\Fillable;
use LaraAttr\Attributes\Hidden;
use LaraAttr\Attributes\Validate;

class UserSchema
{
    #[Fillable]
    #[Validate('required|string|max:255')]
    public string $name;

    #[Fillable]
    #[Validate('required|email|unique:users,email,{id}')]
    public string $email;

    #[Hidden]
    public string $password;
}

2. Attach attributes to your model

app/Models/User.php:

<?php

namespace App\Models;

use Illuminate\Database\Eloquent\Model;
use LaraAttr\Attributes\ApiResource;
use LaraAttr\Attributes\Schema;
use App\Schemas\UserSchema;

#[ApiResource(prefix: '/api/users', name: 'users')]
#[Schema(UserSchema::class)]
class User extends Model
{
    protected $guarded = [];
}

3. Done

On boot, LaraAttr scans app/Models, finds models with #[ApiResource], and registers all five REST endpoints automatically. No controllers, no form requests, no route file edits.

curl http://localhost/api/users          # index
curl http://localhost/api/users/1         # show
curl -X POST http://localhost/api/users -d '{"name":"Alice","email":"a@x.com"}'   # store
curl -X PUT http://localhost/api/users/1 -d '{"name":"Alice2","email":"a@x.com"}' # update
curl -X DELETE http://localhost/api/users/1   # destroy

Core concept — why a separate Schema class?

LaraAttr keeps Model and Schema as separate classes.

// Model — the Eloquent ORM class
#[ApiResource(prefix: '/api/users')]
#[Schema(UserSchema::class)]
class User extends Model {}

// Schema — field definitions (typed properties + attributes)
class UserSchema
{
    #[Fillable]
    #[Validate('required|string')]
    public string $name;
}

Why:

  1. Avoids the Eloquent conflict. Eloquent models manage attributes through the magic __get / __set methods backed by the $attributes array. Declaring a typed property like public string $name directly on a model breaks this — PHP gives the real property precedence over the magic methods, and the ORM stops working. Putting fields on a separate schema class sidesteps the whole issue.
  2. IDE support. A schema class is a plain PHP class, so you get full autocompletion, rename refactors, and static analysis.
  3. Reuse. The schema is the single source of truth shared by validation, response shaping, and OpenAPI docs. One edit updates everything.
  4. DDD-friendly. It cleanly separates the domain layer from the infrastructure layer.

Attribute reference

#[ApiResource]

Target: Class (Model) Required: yes

#[ApiResource(
    prefix: '/api/users',                              // route prefix (required)
    name: 'users',                                     // route-name prefix (optional, derived from prefix if null)
    only: ['index', 'show', 'store', 'update', 'destroy'],  // actions to expose (optional, defaults to all 5)
    resource: UserResource::class,                     // JsonResource subclass (optional)
)]
class User extends Model {}
  • prefix — URL prefix. Slashes are normalized.
  • name — Laravel route name prefix. If null, it's derived from the prefix (/api/usersapi.users).
  • only — which CRUD actions to expose. Pass ['index', 'show'] to get a read-only API.
  • resource — an Illuminate\Http\Resources\Json\JsonResource subclass. When set, every response (index, show, store, update, restore) is piped through it — $resource::collection() for index and new $resource($model) for single-model responses. When omitted, the raw Eloquent model is JSON-serialized as before.

#[Schema]

Target: Class (Model)

#[Schema(UserSchema::class)]
class User extends Model {}

Connects the model to a separate schema class. The schema class is a plain PHP class — completely independent of Eloquent — that holds property-level attributes such as #[Validate], #[Fillable], and #[Hidden].

If no #[Schema] is declared, LaraAttr lets all request data pass through as-is (no validation, no fillable filtering).

#[Validate]

Target: Property (inside a schema class)

class UserSchema
{
    #[Validate('required|string|max:255')]
    public string $name;

    #[Validate('required|email|unique:users,email,{id}')]
    public string $email;

    #[Validate('required|integer|min:0|max:120')]
    public int $age;
}

Standard Laravel validation rules. Both pipe-string ('required|string') and array (['required', 'string']) forms are supported.

Rules are applied automatically on store / update, and a 422 response is returned on failure.

{id} placeholder: Use {id} in validation rules to reference the current record's id. On update, it's replaced with the actual route id (e.g., unique:users,email,5 — self-excluded). On store, it becomes NULL so the rule applies without exclusion. This solves the classic "unique fails on update" problem.

#[Fillable]

Target: Property (inside a schema class)

class UserSchema
{
    #[Fillable]
    public string $name;

    #[Fillable]
    public string $email;

    // password has no Fillable → silently dropped even if a client sends it
    #[Hidden]
    public string $password;
}

Marks a field as writable by clients via store / update. Fields without #[Fillable] are silently dropped from incoming payloads, no matter what the client sends. Built-in mass-assignment protection.

#[Hidden]

Target: Property (inside a schema class)

#[Hidden]
public string $password;

Excludes the field from JSON responses. The model still stores it in the database, but the API never exposes it. Use for password, remember_token, and other sensitive columns.

Independent of #[Fillable] — you can mark a field as both fillable and hidden, accepting it on input but never returning it on output.

#[Middleware]

Target: Class (Model), repeatable

// Apply to all actions
#[Middleware('throttle:60,1')]
class User extends Model {}

// Multiple middlewares on a single line
#[Middleware(['auth:sanctum', 'throttle:60,1'])]
class User extends Model {}

// Per-action scoping — auth only on write endpoints
#[Middleware('auth:sanctum', only: ['store', 'update', 'destroy'])]
#[Middleware('throttle:60,1')]
class User extends Model {}

// Exclude specific actions
#[Middleware('signed', except: ['index'])]
class User extends Model {}

Applies middleware to routes generated for the model. Repeatable (IS_REPEATABLE).

  • middleware — a single string or an array of middleware names.
  • only — actions this middleware applies to. Empty (default) = all actions.
  • except — actions to exclude. only takes precedence over except.

Breaking change in v0.2.0: The old variadic form #[Middleware('a', 'b')] no longer works because the second positional argument is now $only. Use #[Middleware(['a', 'b'])] instead.

#[With]

Target: Class (Model), repeatable

// Eager-load on both index and show (default)
#[With(['profile', 'tags'])]
class User extends Model {}

// Scope to specific actions
#[With(['comments'], only: ['show'])]
class Article extends Model {}

Eager-loads the given Eloquent relations on index and/or show responses.

  • relations — a string or array of relation names (supports dot-paths like author.profile).
  • only — actions to apply to. Defaults to ['index', 'show'].

Repeat the attribute to scope different relation sets to different actions:

#[With(['author'])]                   // index + show
#[With(['comments.replies'], only: ['show'])]  // show only
class Article extends Model {}

Relations are passed straight to Eloquent's with() (index) or loaded via the query builder (show), so you get eager-loading and avoid N+1 queries automatically.

#[Searchable]

Target: Class (Model)

#[Searchable(['name', 'email', 'bio'])]
class User extends Model {}

// Customize the query parameter name
#[Searchable(['name', 'email'], param: 'q')]
class User extends Model {}

When the index request includes ?search=..., LaraAttr automatically runs a LIKE '%term%' query against each listed column. Multiple columns are OR-combined.

GET /api/users?search=alice
# → SELECT * FROM users WHERE (name LIKE '%alice%' OR email LIKE '%alice%' OR bio LIKE '%alice%')

Set param to change the query string parameter (e.g. ?q=alice).

#[Sortable]

Target: Class (Model)

#[Sortable(['created_at', 'name', 'email'])]
class User extends Model {}

?sort=name for ascending, ?sort=-name for descending. Comma-separated values give you multi-column sort (?sort=-created_at,name).

GET /api/users?sort=name           # ORDER BY name ASC
GET /api/users?sort=-created_at    # ORDER BY created_at DESC
GET /api/users?sort=-name,email    # ORDER BY name DESC, email ASC

Security: Columns not in the whitelist are silently dropped. A client trying ?sort=password to sort by an arbitrary column is blocked (SQL injection prevention).

#[Paginate]

Target: Class (Model)

#[Paginate(perPage: 20, maxPerPage: 100)]
class User extends Model {}

// Customize the per-page query parameter name
#[Paginate(perPage: 20, maxPerPage: 100, param: 'limit')]
class User extends Model {}

Auto-paginates the index response. The response shape is Laravel's standard paginator:

{
  "data": [ ... ],
  "current_page": 1,
  "per_page": 20,
  "total": 153,
  "last_page": 8,
  "from": 1,
  "to": 20,
  "first_page_url": "...",
  "next_page_url": "...",
  "prev_page_url": null
}
  • perPage — default page size.
  • maxPerPage — the upper bound a client can request via ?per_page=. Anything above gets clamped down (DoS prevention).
  • param — query parameter name for client-supplied page size (default per_page).
  • Page navigation always uses ?page=N.

#[Cache]

Target: Class (Model)

#[Cache(ttl: 60)]
class Article extends Model {}

// Use a specific cache store
#[Cache(ttl: 300, store: 'redis')]
class Article extends Model {}

Automatically caches index and show responses. Every write (store / update / destroy / restore) automatically invalidates the cache.

  • ttl — cache lifetime in seconds.
  • store — Laravel cache store name. null falls back to the default store (config('cache.default')).
  • The response header X-Cache: HIT or MISS reports the cache state.
  • The cache key is the model class plus a SHA1 of the full URL. Different query strings get different cache entries.
  • Invalidation strategy: a per-model version counter, bumped on every write. Works on every cache store — no Redis tags required.

#[Event]

Target: Class (Model), repeatable

#[Event(UserCreated::class, on: 'created')]
#[Event(UserUpdated::class, on: 'updated')]
#[Event(UserDeleted::class, on: 'deleted')]
class User extends Model {}

// Wildcard — fires the same event on all four lifecycle actions
#[Event(UserChanged::class, on: '*')]
class User extends Model {}

Auto-dispatches an event on a CRUD lifecycle action.

Allowed on values:

  • 'created' — after store
  • 'updated' — after update
  • 'deleted' — after destroy
  • 'restored' — after restore (SoftDelete models only)
  • '*' — fires on all four

Event class convention: the constructor must accept the model instance as its single argument.

class UserCreated
{
    public function __construct(public User $user) {}
}

LaraAttr instantiates events as new $eventClass($model) and dispatches them via the global event() helper. Listeners receive the model on $event->user.

#[SoftDelete]

Target: Class (Model)

use Illuminate\Database\Eloquent\SoftDeletes;

#[ApiResource(prefix: '/api/articles')]
#[SoftDelete]
class Article extends Model
{
    use SoftDeletes;  // ← you must add the Eloquent trait yourself
}

Important: #[SoftDelete] is a marker attribute. You must also use SoftDeletes; on the model class — LaraAttr does not inject the trait at runtime.

When #[SoftDelete] is present:

  • destroy() calls delete(), which the trait turns into a soft delete.
  • index / show automatically exclude trashed rows (Eloquent default).
  • An extra route is registered: POST /api/articles/{id}/restore — restores a soft-deleted row.

Don't forget to add $table->softDeletes(); to your migration.

Auto-generated routes

For every model with #[ApiResource], the following five routes are registered (subset configurable via only):

HTTP URI Action Route name (e.g. name='users')
GET /api/users index users.index
POST /api/users store users.store
GET /api/users/{id} show users.show
PUT/PATCH /api/users/{id} update users.update
DELETE /api/users/{id} destroy users.destroy

If #[SoftDelete] is also present:

HTTP URI Action Route name
POST /api/users/{id}/restore restore users.restore

Exposing only a subset:

#[ApiResource(prefix: '/api/posts', only: ['index', 'show'])]
class Post extends Model {}
// → only GET /api/posts and GET /api/posts/{id} are registered

Using route names:

route('users.show', ['id' => 1]);   // /api/users/1
route('users.index');                // /api/users

Query features

Combining #[Searchable], #[Sortable], and #[Paginate] gives you a powerful list API:

#[ApiResource(prefix: '/api/products')]
#[Searchable(['name', 'description'])]
#[Sortable(['price', 'name', 'created_at'])]
#[Paginate(perPage: 24, maxPerPage: 100)]
class Product extends Model {}
# Search only
GET /api/products?search=keyboard

# Sort only (price descending)
GET /api/products?sort=-price

# Pagination only
GET /api/products?page=3

# Custom page size
GET /api/products?per_page=50

# All combined
GET /api/products?search=keyboard&sort=-price&page=2&per_page=24

The three attributes are independent — pick whichever subset you need.

Response caching

#[Cache(ttl: 300)]   // 5 minutes
class Article extends Model {}

Flow:

  1. First GET request → DB query → JSON response → stored in cache → response header X-Cache: MISS
  2. Second GET request → cache hit → cached JSON returned as-is → response header X-Cache: HIT
  3. POST/PUT/DELETE → DB write → version counter +1 → all old cache keys invalidated
  4. Next GET → cache miss (new version) → DB re-query

Cache key format:

laraattr:{ModelClassName}:v{N}:{sha1(fullUrl)}

Different query strings produce different cache entries automatically (?page=1 and ?page=2 are cached separately).

Invalidation policy:

  • Every write through the API invalidates the cache automatically.
  • Direct DB changes that bypass the API (artisan tinker, raw SQL, etc.) cannot invalidate the cache — entries can become stale, so be aware.
  • The cache store does not need to be Redis or Memcached. The version-counter strategy works on every store.

Event dispatch

#[Event(OrderPlaced::class, on: 'created')]
#[Event(OrderShipped::class, on: 'updated')]
#[Event(OrderCancelled::class, on: 'deleted')]
class Order extends Model {}

Event class:

class OrderPlaced
{
    public function __construct(public Order $order) {}
}

Listener:

class SendOrderConfirmationEmail
{
    public function handle(OrderPlaced $event): void
    {
        Mail::to($event->order->customer)->send(new OrderConfirmation($event->order));
    }
}

Allowed on values: created, updated, deleted, restored, * (wildcard)

Dispatch timing:

  • created — after the store action, just before the response is returned
  • updated — after the update action
  • deleted — after the destroy action (the model instance is delivered to the listener in its trashed state)
  • restored — after the restore action
  • * — fires the same event on all four

Queued listeners: if a listener implements ShouldQueue, Laravel queues it automatically. LaraAttr always dispatches synchronously — queueing is the listener's responsibility.

Soft delete

1. Migration

Schema::create('articles', function (Blueprint $table) {
    $table->id();
    $table->string('title');
    $table->text('body');
    $table->timestamps();
    $table->softDeletes();   // ← deleted_at column
});

2. Model

use Illuminate\Database\Eloquent\Model;
use Illuminate\Database\Eloquent\SoftDeletes;
use LaraAttr\Attributes\ApiResource;
use LaraAttr\Attributes\SoftDelete;

#[ApiResource(prefix: '/api/articles')]
#[SoftDelete]
class Article extends Model
{
    use SoftDeletes;
}

3. Usage

# Standard destroy → soft delete (deleted_at is filled in)
DELETE /api/articles/1

# Soft-deleted rows are automatically excluded from index/show
GET /api/articles         # row 1 not visible
GET /api/articles/1       # 404

# Restore
POST /api/articles/1/restore

The POST /{id}/restore route is only registered for #[SoftDelete] models.

OpenAPI doc generation

php artisan laraattr:docs

Writes an OpenAPI 3.0.3 spec to public/openapi.json.

Options:

# Custom output path
php artisan laraattr:docs --output=storage/api-docs/openapi.json

# Pretty-print with indentation
php artisan laraattr:docs --output=public/openapi.json --pretty

What gets auto-documented:

Item Source
paths #[ApiResource] + only
operations the 5 CRUD actions + restore (when SoftDelete)
tags ApiResource.name or derived from prefix
request body schema #[Validate] rules from the schema class
response schema every schema field minus #[Hidden]
query parameters #[Searchable] → search, #[Sortable] → sort, #[Paginate] → page/per_page
paginated response wrapper when #[Paginate] is present, wraps in {data, current_page, per_page, total, ...}
422 response added to store/update automatically
404 response added to show/update/destroy/restore automatically
security bearer auth added automatically when #[Middleware('auth:*')] is detected

Validation rule → OpenAPI mapping:

Laravel rule OpenAPI result
string {type: string}
integer / int {type: integer}
numeric {type: number}
boolean / bool {type: boolean}
array {type: array}
email {type: string, format: email}
url {type: string, format: uri}
uuid {type: string, format: uuid}
date {type: string, format: date}
min:N minimum for numeric types, minLength otherwise
max:N maximum for numeric types, maxLength otherwise
in:a,b,c {enum: [a, b, c]}
nullable {nullable: true}
required added to the parent's required array
unique:..., exists:... ignored (server-side rules only)

Rule order does not matter (string\|max:255 and max:255\|string produce identical output).

The generated openapi.json can be loaded directly into Swagger UI, Redoc, Stoplight Elements, etc.

Configuration

config/laraattr.php:

<?php

return [
    /*
    |--------------------------------------------------------------------------
    | Model paths
    |--------------------------------------------------------------------------
    |
    | Map of filesystem paths to PSR-4 namespaces. LaraAttr scans these
    | locations for Eloquent models that declare the #[ApiResource] attribute
    | and registers REST routes for them automatically.
    |
    */
    'models' => [
        app_path('Models') => 'App\\Models',
    ],
];

Scanning multiple directories:

'models' => [
    app_path('Models')           => 'App\\Models',
    app_path('Domain/User')      => 'App\\Domain\\User',
    app_path('Domain/Order')     => 'App\\Domain\\Order',
],

Nested directories are walked recursively:

app/Models/
├── User.php           ← App\Models\User
├── Post.php           ← App\Models\Post
└── Admin/
    └── Setting.php    ← App\Models\Admin\Setting (auto-discovered)

route:cache compatibility

php artisan route:cache

LaraAttr registers routes via controller-action references (not closures), so it's fully compatible with route:cache. When routes are cached, the boot-time model scan is skipped automatically — the cache wins.

Internally:

Route::get('/', [GenericCrudController::class, 'index'])
    ->defaults('_modelClass', User::class)
    ->name('index');

The model class is passed to the controller via a route default. There's no closure capture, so the routes are fully serializable.

Security notes

LaraAttr provides the following security guards out of the box:

  1. Mass-assignment protection. Only fields with #[Fillable] are accepted on store/update. A client sending is_admin: true is silently dropped if is_admin isn't fillable.
  2. SQL injection prevention (sorting). #[Sortable] is whitelist-based. A client trying ?sort=password to sort by an arbitrary column is silently ignored.
  3. DoS prevention (pagination). #[Paginate]'s maxPerPage enforces a hard upper bound on page size. ?per_page=99999999 is safe.
  4. Response field masking. #[Hidden] fields are automatically excluded from every response (index, show, store, update).
  5. Authentication / authorization. Use standard Laravel middleware via #[Middleware('auth:sanctum')]. Scope to specific actions with only: / except: for fine-grained control.

Testing

To test LaraAttr itself, use Orchestra Testbench.

Unit tests (pure classes like RuleConverter, SchemaResolver):

use LaraAttr\Resolvers\SchemaResolver;

it('extracts schema definition', function () {
    $resolver = new SchemaResolver();
    $def = $resolver->resolve(MySchema::class);
    expect($def->fillable)->toBe(['name', 'email']);
});

Feature tests (routes / controllers):

use LaraAttr\LaraAttrServiceProvider;
use Orchestra\Testbench\TestCase;

abstract class BaseTestCase extends TestCase
{
    protected function getPackageProviders($app): array
    {
        return [LaraAttrServiceProvider::class];
    }

    protected function defineEnvironment($app): void
    {
        $app['config']->set('database.default', 'testing');
        $app['config']->set('database.connections.testing', [
            'driver'   => 'sqlite',
            'database' => ':memory:',
        ]);
        $app['config']->set('laraattr.models', [
            __DIR__.'/Fixtures/Models' => 'YourTests\\Fixtures\\Models',
        ]);
    }
}

it('lists users', function () {
    $this->getJson('/api/users')->assertOk();
});

LaraAttr ships with 110 tests of its own (Pest-based):

vendor/bin/pest

Troubleshooting

Route::has('users.index') returns false

LaraAttr 0.5+ calls Route::getRoutes()->refreshNameLookups() immediately after registration so the named-route lookup is always in sync. On older versions — or if you're calling RouteResolver directly — call refreshNameLookups() yourself after registering.

Models aren't being auto-discovered

  1. Make sure your model directory is listed in models in config/laraattr.php.
  2. Confirm the model class actually has the #[ApiResource] attribute.
  3. Abstract classes, interfaces, and traits are skipped automatically.
  4. Verify the PSR-4 mapping (directory path → namespace) is correct.
  5. Run composer dump-autoload to refresh the autoloader.

Class '...' not found errors

LaraAttr normalizes relative paths (__DIR__.'/../Models') via realpath(). If you still hit issues, switch to an absolute helper like app_path('Models').

Cache isn't being invalidated

Direct DB changes that bypass the API (artisan tinker, raw SQL, writes from another service) can't be observed by LaraAttr. Either flush the cache for that model manually, or route every write through the LaraAttr API.

Pagination response is too large

If you don't add #[Paginate], index returns every row (dangerous!). Always declare #[Paginate] on production models.

Known limitations

  1. Searchable is plain LIKE only. No full-text search, no relation search.
  2. Cannot force listeners onto a queue. A listener must implement ShouldQueue itself.
  3. No force-delete route. A DELETE /{id}/force style endpoint is not provided automatically beyond soft delete.
  4. Cache invalidation on bypass writes is not supported. Observer-based auto-invalidation is on the v0.5+ roadmap.
  5. Multi-DB connections are untested. They likely work, but no test coverage exists.

Requirements

  • PHP 8.1+ (attributes, readonly properties, first-class callables)
  • Laravel 10 / 11 / 12
  • Composer 2.x

License

MIT License — see LICENSE.