承接 tcds-io/laravel-prince 相关项目开发

从需求分析到上线部署,全程专人跟进,保证项目质量与交付效率

邮箱:yvsm@zunyunkeji.com | QQ:316430983 | 微信:yvsm316

tcds-io/laravel-prince

最新稳定版本:1.13.0

Composer 安装命令:

composer require tcds-io/laravel-prince

包简介

A Simple way to create rest API resources for your Laravel models.

README 文档

README

"Dearly beloved, we are gathered here today to get through this thing called life." — Prince

Turn any Eloquent model into a fully working REST API — no controllers, no form requests, no manual routes.

ModelResourceBuilder::create()
    ->authorizer(fn(RequestContext $ctx) => in_array($ctx->permission, $user->permissions))
    ->resource(Invoice::class)
    ->resource(Product::class)
    ->routes();

What you get

For every registered model the following routes are created automatically, using the model's $table as the URL segment:

Method Path Action
GET /invoices Paginated list
GET /invoices/_schema Column schema, nested resources, permissions
GET /invoices/{id} Single record (+ embedded nested lists)
POST /invoices Create one — or batch-create many
PATCH /invoices/{id} Update one
PATCH /invoices Batch-update many
DELETE /invoices/{id} Delete one
DELETE /invoices Batch-delete many

Plus one route at the builder group level:

Method Path Action
GET /_schema Schema for all registered resources

And when global search is enabled:

Method Path Action
GET /search Full-text search across all opted-in resources

Installation

composer require tcds-io/laravel-prince

No configuration needed — call it directly from your routes file.

Usage

Register your resources inside routes/api.php:

use Tcds\Io\Prince\ModelResourceBuilder;
use Tcds\Io\Prince\AuthorizerContext;

Route::prefix('/api/backoffice')->group(function () {
    ModelResourceBuilder::create()
        ->authorizer(fn(AuthorizerContext $ctx) => in_array($ctx->permission, $request->user()->permissions))
        ->resource(Invoice::class, globalSearch: true)
        ->resource(Product::class, globalSearch: true)
        ->routes();
});

The package reads each model's $table and $casts properties to determine route prefixes and column types — nothing to configure.

Nested resources

Register sub-resources via a callback. Nested routes are scoped to the parent automatically, and the parent's FK is inferred from the table name (invoice_id for invoices):

ModelResourceBuilder::create()
    ->authorizer(fn(RequestContext $ctx) => in_array($ctx->permission, $user->permissions))
    ->resource(
        model: Invoice::class,
        resources: fn(ModelResourceBuilder $b) => $b
            ->resource(InvoiceItem::class),
    )
    ->routes();

This registers the full nested route set under /invoices/{invoiceId}/items, with every request validated against the parent's existence.

GET response includes inner lists

GET /invoices/{id} automatically embeds each registered nested resource as an inner list — $with eager loads on the model are ignored, keeping the API shape fully controlled by what you register:

{
  "data": {
    "id": 1,
    "title": "November invoice",
    "amount": 299.00,
    "items": [
      {
        "id": 10,
        "description": "Widget A",
        "price": 49.00,
        "_resource": "/invoices/1/items/10"
      },
      {
        "id": 11,
        "description": "Widget B",
        "price": 99.00,
        "_resource": "/invoices/1/items/11"
      }
    ]
  },
  "meta": {
    "resource": "invoices",
    "schema": [
      ...
    ],
    "resources": [
      "items"
    ]
  }
}

List responses

Every item in a paginated list includes a _resource field — the direct URL to that record, including any outer route prefix and parent path for nested resources:

{
  "data": [
    {
      "id": 1,
      "title": "Invoice A",
      "_resource": "/api/backoffice/invoices/1"
    },
    {
      "id": 2,
      "title": "Invoice B",
      "_resource": "/api/backoffice/invoices/2"
    }
  ],
  "meta": {
    "resource": "invoices",
    "schema": [
      ...
    ],
    "current_page": 1,
    "per_page": 10,
    "total": 2,
    "last_page": 1
  }
}

Pagination

Control page size with ?limit=N (default 10, max 100):

GET /invoices?limit=25
GET /invoices?limit=25&page=2

The maximum is configurable — set a builder-level default or override it per resource:

// All resources in this builder cap at 50
ModelResourceBuilder::create(maxLimit: 50)
    ->resource(Invoice::class)
    ->resource(Product::class, maxLimit: 10)  // this one caps at 10
    ->routes();

Values above maxLimit are silently clamped to it.

Batch operations

All three write endpoints support a batch mode alongside their single-record form. Every batch request is wrapped in a database transaction — if any record fails, the whole operation is rolled back.

Batch create

Send a {"data": [...]} body (an object with a single data array key) to create multiple records in one request. Returns the IDs of all created records:

POST /invoices
{"data": [{"title": "Invoice A", "amount": 100}, {"title": "Invoice B", "amount": 200}]}

→ 200 {"data": [{"id": 1}, {"id": 2}]}

A plain object body (no data wrapper, or data alongside other keys) is treated as a single-record create as usual:

POST /invoices
{"title": "Invoice A", "amount": 100}

→ 200 {"id": 1}

Batch update

PATCH /invoices with a {"data": [...]} body, where each item must include id alongside the fields to update. Returns 204 No Content. Returns 404 if any ID is not found (and rolls back all changes):

PATCH /invoices
{"data": [{"id": 1, "title": "Updated"}, {"id": 2, "amount": 300}]}

→ 204

Batch delete

DELETE /invoices with a {"data": [id, ...]} body. Returns 204 No Content. Returns 404 if any ID is not found ( and rolls back all deletions):

DELETE /invoices
{"data": [1, 2, 3]}

→ 204

Schema

Per-resource schema

GET /invoices/_schema returns the column schema, registered nested resource names, and the permissions the current user holds for that resource. Always accessible regardless of permission settings.

Global schema

GET /_schema (registered at the builder group level) returns the same information for all registered resources in one request:

{
  "data": [
    {
      "resource": "invoices",
      "schema": [
        ...
      ],
      "resources": [
        "items"
      ],
      "permissions": {
        "read": "invoices:read",
        "create": "invoices:write"
      }
    },
    {
      "resource": "products",
      "schema": [
        ...
      ],
      "resources": [],
      "permissions": {
        "read": "products:read"
      }
    }
  ]
}

Always accessible regardless of permissions, and like the per-resource schema, only shows permissions the current user holds.

Schema response shape

The per-resource /_schema response (used directly or as an element in the global /_schema) looks like:

{
  "resource": "invoices",
  "schema": [
    {
      "name": "id",
      "type": "integer"
    },
    {
      "name": "title",
      "type": "text"
    },
    {
      "name": "amount",
      "type": "number"
    },
    {
      "name": "created_at",
      "type": "datetime"
    },
    {
      "name": "updated_at",
      "type": "datetime"
    }
  ],
  "resources": [
    "items"
  ],
  "permissions": {
    "read": "invoices:read",
    "create": "invoices:write",
    "update": "invoices:write",
    "delete": "invoices:delete"
  }
}

Only permissions the current user actually holds appear in the map — so a read-only user sees only read. Endpoints with 'public' permission are always included. Extra action permissions appear under slug-formatted keys:

"permissions": {
"read": "invoices:read",
"post-import": "invoices:write",
"get-id-preview": "invoices:read"
}

Filtering

Full-text search

?search=value matches against all non-datetime columns using OR. Operators are inferred from the value:

GET /invoices?search=acme          → exact match across all text columns
GET /invoices?search=%acme%        → LIKE match

Column filter

?{column}=value filters on a specific column. The same operator inference applies:

GET /invoices?title=%acme%         → LIKE
GET /invoices?amount=>100          → greater than 100
GET /invoices?amount=<=500         → less than or equal to 500
GET /invoices?amount=100/500       → between 100 and 500
GET /invoices?status=active        → exact match (also works for enums)

Operator reference

Value pattern Operator Column types
%foo% LIKE text, enum
>N > integer, number, datetime
<N < integer, number, datetime
>=N >= integer, number, datetime
<=N <= integer, number, datetime
from/to BETWEEN integer, number, datetime
anything else = all

Global search

Opt any resource into cross-resource full-text search with globalSearch: true. A single GET /search?q=value endpoint is registered covering all opted-in resources:

ModelResourceBuilder::create()
    ->authorizer(fn(RequestContext $ctx) => in_array($ctx->permission, $user->permissions))
    ->resource(Invoice::class, globalSearch: true)
    ->resource(Product::class, globalSearch: true)
    ->resource(Customer::class)          // excluded from search
    ->routes();
GET /search?q=acme
GET /search?q=%acme%    → LIKE
{
  "data": [
    {
      "id": 1,
      "description": "Acme Corp",
      "resource": "invoices",
      "link": "/api/backoffice/invoices/1"
    },
    {
      "id": 7,
      "description": "Acme Widget",
      "resource": "products",
      "link": "/api/backoffice/products/7"
    }
  ]
}

Each result has id, description (first matching text column), resource (table name), and link (full URL including any outer route prefix). Each record appears at most once per resource even when multiple text columns match.

Permissions

Authorizer

A closure that returns true to allow access or false to deny it (403). Evaluated per request — so it runs after authentication middleware, can read the request, and supports any auth strategy. Shared across all resources in the builder — set once, applied everywhere.

All parameters are resolved via the Laravel IoC container. Declare RequestContext to receive the current request's method, path, and permission string:

use Tcds\Io\Prince\AuthorizerContext;

ModelResourceBuilder::create()
    ->authorizer(fn(AuthorizerContext $ctx) => in_array($ctx->permission, $request->user()->permissions))
    ->resource(Invoice::class)
    ->resource(Product::class)
    ->routes();

RequestContext is optional — declare it only when you need it. Other injectables (services, etc.) are resolved from the container as usual:

// Check permission against the authenticated user
fn(RequestContext $ctx) => in_array($ctx->permission, Auth::user()?->permissions ?? [])

// Inject a service — no RequestContext needed
fn(AuthService $auth) => $auth->isAdmin()

// Use both
fn(RequestContext $ctx, AuthService $auth) => $auth->can($ctx->permission)

// Flat allow/deny (e.g. for public read-only endpoints)
fn() => false

Resource permissions

The permission each action requires. Defaults to the strings below; override per resource when your app uses different permission names:

Action Default required permission
list + get default:model.read
create default:model.create
update default:model.update
delete default:model.delete
ModelResourceBuilder::create()
    ->authorizer(fn(RequestContext $ctx) => in_array($ctx->permission, Auth::user()?->permissions ?? []))
    ->resource(
        model: Invoice::class,
        permissions: [
            'read'   => 'invoices:read',
            'create' => 'invoices:write',
            'update' => 'invoices:write',
            'delete' => 'invoices:delete',
        ],
    )
    ->routes();

Besides regular permission strings, one reserved keyword controls a special behaviour:

Keyword Behaviour
'public' Route is registered without permission middleware — anyone can access it

To disable an endpoint entirely, simply omit its key from permissions. The route will not be registered and the framework returns 404.

Reserved word: 'public' must not be used as an actual permission name in your application. It is intercepted by the library before any user permission check.

// Read-only resource: anyone can list/get, create/update/delete are not registered
ModelResourceBuilder::create()
    ->authorizer(fn(RequestContext $ctx) => in_array($ctx->permission, $user->permissions))
    ->resource(
        model: Product::class,
        permissions: [
            'read' => 'public',
        ],
    )
    ->routes();

Type inference

The package inspects each database column and applies the right PHP type automatically. $casts on your model takes priority over the raw DB type.

DB / cast type API type Parsed as
bigint, integer integer (int)
decimal, float number (float)
datetime datetime Carbon
immutable_datetime datetime CarbonImmutable
Any BackedEnum enum MyEnum::from(...)
Anything else text passthrough

Enum values are automatically included in the schema:

{
  "name": "status",
  "type": "enum",
  "values": [
    "draft",
    "active",
    "cancelled"
  ]
}

Error handling

Situation HTTP response
Record not found 404 Not Found
Missing required permission 403 Forbidden
Invalid value / constraint error 400 Bad Request with error detail

Actions

Register extra endpoints on a resource with actions. Use ResourceAction::{method}() — paths containing {id} are item-level (the record is resolved and injected automatically); all other paths are collection-level.

The action must be an invokable class (a class with __invoke). Laravel's IoC container resolves and calls it, so any type-hinted dependencies are injected automatically.

use Tcds\Io\Prince\AuthorizerContext;
use Tcds\Io\Prince\ResourceAction;

ModelResourceBuilder::create()
    ->authorizer(fn(AuthorizerContext $ctx) => in_array($ctx->permission, $user->permissions))
    ->resource(
        model: Invoice::class,
        permissions: [
            'read'   => 'invoices:read',
            'create' => 'invoices:write',
            'update' => 'invoices:write',
            'delete' => 'invoices:delete',
        ],
        actions: [
            // Collection-level — POST /invoices/import
            ResourceAction::post(
                path: '/import',
                action: ImportInvoicesAction::class,
                permission: 'invoices:write',
            ),

            // Item-level — POST /invoices/{id}/send
            // The matching Invoice is resolved and injected; returns 404 if not found.
            ResourceAction::post(
                path: '/{id}/send',
                action: SendInvoiceAction::class,
                permission: 'invoices:send',
            ),

            // GET /invoices/{id}/pdf
            ResourceAction::get(
                path: '/{id}/pdf',
                action: InvoicePdfController::class,
                permission: 'invoices:read',
            ),
        ],
    )
    ->routes();

Collection actions (/import) are registered before /{id} routes so literal path segments are never captured as record IDs.

Item actions (/{id}/send) resolve the record from the database before calling the action. The model instance is injected by type — any parameter type-hinted with the model class receives it. The full Laravel IoC is available for additional injectables (Request, services, etc.). permission is optional; omit it to allow unauthenticated access to that action.

Events

Every CRUD operation fires a lifecycle event before and after the DB write. Register listeners via standard Laravel event dispatching — no extra configuration needed.

Event When Signature
ResourceCreating before insert (class-string $modelName, array $data)
ResourceCreated after insert (Model $model)
ResourceUpdating before update (Model $model, array $data)
ResourceUpdated after update (Model $model)
ResourceDeleting before delete (Model $model)
ResourceDeleted after delete (int|string $modelId)
use Tcds\Io\Prince\Events\ResourceCreated;
use Tcds\Io\Prince\Events\ResourceCreating;

// Side effect — send notification after create
Event::listen(ResourceCreated::class, function (ResourceCreated $event): void {
    if ($event->model instanceof Invoice) {
        Notification::send($event->model->user, new InvoiceCreatedNotification($event->model));
    }
});

// Data mutation — slugify title before save
Event::listen(ResourceCreating::class, function (ResourceCreating $event): void {
    if (isset($event->data['title'])) {
        $event->data['slug'] = Str::slug($event->data['title']);
    }
});

ResourceCreating and ResourceUpdating implement MutableDataEvent — any changes to $event->data are applied to the actual DB write.

Overriding default events

Override any event per resource by passing an events array keyed by lifecycle name. Unspecified keys keep their defaults:

use Tcds\Io\Prince\Events\ResourceCreating;

ModelResource::of(
    model: Invoice::class,
    events: [
        'creating' => InvoiceCreating::class, // replaces ResourceCreating
        'created'  => InvoiceCreated::class,  // replaces ResourceCreated
    ],
);

The custom event must expose a public mutable $data property to participate in data mutation:

use Tcds\Io\Prince\Events\MutableDataEvent;

class InvoiceCreating implements MutableDataEvent
{
    public function __construct(
        public readonly string $modelName,
        public array $data,
    ) {}
}

Custom URL segment

Override the URL segment with segment when you want a different path than the table name:

ModelResourceBuilder::create()
    ->authorizer(fn(RequestContext $ctx) => in_array($ctx->permission, $user->permissions))
    ->resource(model: Invoice::class, segment: 'bills')
    ->routes();
// Routes registered at /bills/... — table name remains invoices in meta/schema

Requirements

  • PHP ^8.4
  • Laravel ^12.0

统计信息

  • 总下载量: 290
  • 月度下载量: 0
  • 日度下载量: 0
  • 收藏数: 0
  • 点击次数: 3
  • 依赖项目数: 0
  • 推荐数: 0

GitHub 信息

  • Stars: 0
  • Watchers: 0
  • Forks: 0
  • 开发语言: PHP

其他信息

  • 授权协议: MIT
  • 更新时间: 2026-02-25

承接程序开发

PHP开发

VUE

Vue开发

前端开发

小程序开发

公众号开发

系统定制

数据库设计

云部署

网站建设

安全加固