Declare a resource. Get the whole API.

Laravel Rest Api turns your Eloquent models into a complete JSON API: search with filters and nested includes, nested writes in one transaction, actions and metrics, with your policies applied at every level. One resource, one controller, one route line.

composer require lomkit/laravel-rest-api

PHP 8.3+ · Laravel 13.30+ · MIT license

app/Rest/Resources/ProductResource.php
class ProductResource extends Resource
{
    public static string $model = Product::class;

    public function fields(): array
    {
        return [
            ID::make('id'),
            Integer::make('category_id'),
            Text::make('name'),
            Integer::make('price'),
        ];
    }

    public function relations(): array
    {
        return [
            BelongsTo::make('category', CategoryResource::class),
        ];
    }
}
routes/api.php
Rest::resource('products', ProductsController::class);
POST/api/products/search
{
  "search": {
    "filters": [
      { "field": "price", "operator": "<", "value": 100 }
    ],
    "includes": [
      { "relation": "category" }
    ]
  }
}
Response200 OKapplication/json
{
  "current_page": 1,
  "data": [
    {
      "id": 1, "category_id": 1, "name": "Desk lamp", "price": 39,
      "category": { "id": 1, "name": "Lighting" }
    }
  ],
  "from": 1, "last_page": 1, "per_page": 50, "to": 1, "total": 1,
  "meta": {}
}

Filters on declared fields, includes of declared relations, pagination. Nothing else gets through.

Filters and relation filters Nested includes Aggregates Text search and Scout engines Nested writes Actions Metrics Policies and gates Perimeter queries Precognition OpenAPI Exports
Declare

An API in minutes, not sprints

Run rest:quick-start, or write a resource by hand: typed fields, relations, scopes, instructions, actions and metrics. One route line registers its endpoints.

  • Typed fields: ID, Text, Number, Integer, Boolean, Date, DateTime, Enum and Json.
  • Generators for resources, controllers, policies, actions, instructions, metrics and responses.
  • What you do not declare is not exposed.
Write your first resource
routes/api.php
use Lomkit\Rest\Facades\Rest;

Rest::resource('products', ProductsController::class);
Terminal

$ php artisan route:list --path=api/products

GET|HEADapi/productsproducts.details
POSTapi/products/searchproducts.search
POSTapi/products/mutateproducts.mutate
POSTapi/products/actions/{action}products.operate
POSTapi/products/metrics/{metric}products.metric
DELETEapi/productsproducts.destroy

Showing [6] routes. ->withSoftDeletes() adds restore and force delete.

Authorization

Your policies, applied at every level

The package calls your Laravel policies on every resource a request reaches, scopes every query with your perimeter, and bounds every list with limits you can tune.

  • A denied check answers 403 before any 422, and before any query.
  • Each check runs once per request; the abilities you choose stay cached between requests.
  • With the denials renderer, a 403 lists each refused check with its path, ability and key.
Read about policies

What every request goes through

  1. Limits

    Sizes, depths and totals of every list are bounded. A list over its limit is one 422 on that list.

  2. Entry checks

    viewAny on every resource the payload reaches, create on every created one, before validation.

  3. Validation

    Only declared fields, operators, relations and scopes. Anything else is a 422 on its key.

  4. Perimeter

    perimeterQuery() scopes searches, includes, writes, deletions and actions.

  5. Row checks

    update, delete and link abilities, in one batch, before the first write.

  6. Gates

    Each row can tell the client what the user may do with it.

app/Rest/Resources/ProductResource.php
public function perimeterQuery(Builder $query): Builder
{
    return $query->where('team_id', $this->request()->user()->team_id);
}
Frontends

Built for the frontend you actually ship

Your interface asks the API what the user may do, instead of guessing. Buttons, forms and bulk actions follow your policies, and forms validate as the user types.

  • Details per user: fields, operators, relations, actions and authorizations, only the parts you ask for.
  • Per-row gates with the messages of your policies.
  • Laravel Precognition on search, mutate and actions.
See the details endpoint

Products

New product
  • Desk lamp

    Lighting · 39

    EditDelete
  • Floor lamp

    Lighting · 129

    EditDelete

    This product has orders.

The unit price field must be at least 0.

"authorizations": {
  "viewAny": { "allowed": true, "message": null },
  "create": { "allowed": true, "message": null }
}

The details endpoint answers for the signed-in user: show the button, or not.

Documented

Documented and typed, out of the box

Scramble documents every endpoint from your declarations: one schema per filterable field with its operators and values, the records, the actions and the metrics. The schemas are tested against real responses.

  • An OpenAPI document your clients can generate code from.
  • Typed PHP all the way: Larastan level 10.
  • A Laravel Boost skill that knows the package.
Generate the OpenAPI document
/docs/api
components.schemas.ShowFilterStatus· oneOf[0]
{
  "type": "object",
  "properties": {
    "field": { "type": "string", "const": "status" },
    "operator": { "type": "string", "enum": ["=", "!="] },
    "value": {
      "type": "string",
      "enum": ["planned", "on_sale", "sold_out"]
    },
    "type": { "type": "string", "enum": ["and", "or"] }
  },
  "required": ["field", "value"],
  "additionalProperties": false
}

OpenAPI

Through Scramble, with a schema per field, operator and relation.

Level 10

Larastan at its highest level on the package source.

Boost

A Laravel Boost skill ships with the package.

Scale

Grows with your app

Start with SQL, add a search engine when the data grows. Queue the heavy actions, chart the numbers, export what a user sees, and keep one set of rules for all of it.

Explore search engines
Text search, on your terms

Start with LIKE, move to the full-text index of your database, then to a Scout engine, behind the same search.text key.

ContainsFullTextMeilisearchTypesenseAlgoliaTurbopufferdatabasecollection
Metrics with the same filters

Each metric gets its route, reads the perimeter and the search of the request, and can stay cached.

valuepartitionprogresstrendtable
$rows = Rest::search(OrderResource::class)
    ->as($request->user())
    ->payload($request->input('search', []))
    ->lazy();

foreach ($rows as $row) {
    fputcsv($file, [$row['id'], $row['reference']]);
}
Exports and jobs

Replay a client search outside HTTP, as a given user, with the same perimeter and policies. Rows stream by chunks.

Queued and batched actions

Laravel queue attributes, batch progress on its own route, the worker runs as the user who asked.

#[Queue('exports')]
class ExportOrders extends Action
    implements ShouldQueue
Writes that hold under load

Every mutation runs in one transaction, which can retry on deadlocks. Checks run in one batch before the first write, and decisions are cached.

transactions.attemptsauthorizationCache()

Before / after

Less code, and it answers more requests

A typical hand-written controller for products, which also needs two form requests, a JSON resource and its routes, next to the resource that replaces them, plus a controller that names it and one route line.

app/Http/Controllers/ProductController.php
46 lines + 3 files
class ProductController extends Controller
{
    public function index(Request $request)
    {
        Gate::authorize('viewAny', Product::class);

        $validated = $request->validate([
            'max_price' => ['nullable', 'integer'],
            'with_category' => ['boolean'],
            'per_page' => ['in:10,25,50'],
        ]);

        $products = Product::query()
            ->when($validated['max_price'] ?? null, fn ($query, $price) => $query->where('price', '<', $price))
            ->when($validated['with_category'] ?? false, fn ($query) => $query->with('category'))
            ->latest('id')
            ->paginate($validated['per_page'] ?? 50);

        return ProductResource::collection($products);
    }

    public function store(StoreProductRequest $request)
    {
        Gate::authorize('create', Product::class);

        return new ProductResource(Product::create($request->validated()));
    }

    public function update(UpdateProductRequest $request, Product $product)
    {
        Gate::authorize('update', $product);

        $product->update($request->validated());

        return new ProductResource($product);
    }

    public function destroy(Product $product)
    {
        Gate::authorize('delete', $product);

        $product->delete();

        return response()->noContent();
    }
}
app/Rest/Resources/ProductResource.php
21 lines
class ProductResource extends Resource
{
    public static string $model = Product::class;

    public function fields(): array
    {
        return [
            ID::make('id'),
            Integer::make('category_id'),
            Text::make('name')->rules('max:255')->creationRules('required'),
            Integer::make('price')->rules('min:0'),
        ];
    }

    public function relations(): array
    {
        return [
            BelongsTo::make('category', CategoryResource::class),
        ];
    }
}
Filters One, written by hand: price below a value. Every declared field, with the operators of its type, nested groups and relation paths.
Includes The category, on or off. Every declared relation, nested, filtered, sorted and limited per parent row.
Writes One product per request. Batches with nested relations, in one transaction.
Authorization One Gate call per method. Policies on every resource the request reaches, row checks before the first write.
Documentation To write and keep in sync. OpenAPI generated from the declarations.

A hand-written controller is the right tool for a one-off endpoint. When the frontend asks for one more filter, one more include or one more bulk edit, the resource already answers.

Your next API is one resource away

Install the package, run the quick start, and send your first search in a few minutes.

php artisan rest:quick-start