devvelvet / laraattr
Build Laravel APIs by adding PHP 8 attributes to your models.
Requires
- php: ^8.1
- illuminate/database: ^10.0 || ^11.0 || ^12.0
- illuminate/http: ^10.0 || ^11.0 || ^12.0
- illuminate/routing: ^10.0 || ^11.0 || ^12.0
- illuminate/support: ^10.0 || ^11.0 || ^12.0
- illuminate/validation: ^10.0 || ^11.0 || ^12.0
- symfony/finder: ^6.0 || ^7.0
Requires (Dev)
- orchestra/testbench: ^8.0 || ^9.0 || ^10.0
- pestphp/pest: ^2.0 || ^3.0
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 +uniqueself-exclusion via{id})DELETE /api/users/{id}auth:sanctumon write endpoints,throttle:60,1on all endpoints- All responses wrapped through
UserResource(JsonResource) - An OpenAPI 3.0 spec via
php artisan laraattr:docs
Table of contents
- Installation
- Quick start
- Core concept — why a separate Schema class?
- Attribute reference
- Auto-generated routes
- Query features
- Response caching
- Event dispatch
- Soft delete
- OpenAPI doc generation
- Configuration
route:cachecompatibility- Security notes
- Testing
- Troubleshooting
- Known limitations
- Requirements
- License
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:
- Avoids the Eloquent conflict. Eloquent models manage attributes through the magic
__get/__setmethods backed by the$attributesarray. Declaring a typed property likepublic string $namedirectly 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. - IDE support. A schema class is a plain PHP class, so you get full autocompletion, rename refactors, and static analysis.
- Reuse. The schema is the single source of truth shared by validation, response shaping, and OpenAPI docs. One edit updates everything.
- 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. Ifnull, it's derived from the prefix (/api/users→api.users).only— which CRUD actions to expose. Pass['index', 'show']to get a read-only API.resource— anIlluminate\Http\Resources\Json\JsonResourcesubclass. When set, every response (index,show,store,update,restore) is piped through it —$resource::collection()for index andnew $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.onlytakes precedence overexcept.
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 likeauthor.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 (defaultper_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.nullfalls back to the default store (config('cache.default')).- The response header
X-Cache: HITorMISSreports 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()callsdelete(), which the trait turns into a soft delete.index/showautomatically 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:
- First GET request → DB query → JSON response → stored in cache → response header
X-Cache: MISS - Second GET request → cache hit → cached JSON returned as-is → response header
X-Cache: HIT - POST/PUT/DELETE → DB write → version counter +1 → all old cache keys invalidated
- 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 thestoreaction, just before the response is returnedupdated— after theupdateactiondeleted— after thedestroyaction (the model instance is delivered to the listener in its trashed state)restored— after therestoreaction*— 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:
- Mass-assignment protection. Only fields with
#[Fillable]are accepted on store/update. A client sendingis_admin: trueis silently dropped ifis_adminisn't fillable. - SQL injection prevention (sorting).
#[Sortable]is whitelist-based. A client trying?sort=passwordto sort by an arbitrary column is silently ignored. - DoS prevention (pagination).
#[Paginate]'smaxPerPageenforces a hard upper bound on page size.?per_page=99999999is safe. - Response field masking.
#[Hidden]fields are automatically excluded from every response (index,show,store,update). - Authentication / authorization. Use standard Laravel middleware via
#[Middleware('auth:sanctum')]. Scope to specific actions withonly:/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
- Make sure your model directory is listed in
modelsinconfig/laraattr.php. - Confirm the model class actually has the
#[ApiResource]attribute. - Abstract classes, interfaces, and traits are skipped automatically.
- Verify the PSR-4 mapping (directory path → namespace) is correct.
- Run
composer dump-autoloadto 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
Searchableis plain LIKE only. No full-text search, no relation search.- Cannot force listeners onto a queue. A listener must implement
ShouldQueueitself. - No force-delete route. A
DELETE /{id}/forcestyle endpoint is not provided automatically beyond soft delete. - Cache invalidation on bypass writes is not supported. Observer-based auto-invalidation is on the v0.5+ roadmap.
- 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.