ワンクリックで
api-development
Build new API endpoints and how to test them properly
Codex または Claude でインストール この Prompt をコピーして Codex、Claude、または他のアシスタントに貼り付けると、Skill ページを確認してインストールできます。
メニュー
Build new API endpoints and how to test them properly
Codex または Claude でインストール この Prompt をコピーして Codex、Claude、または他のアシスタントに貼り付けると、Skill ページを確認してインストールできます。
SOC 職業分類に基づく
| name | api-development |
| description | Build new API endpoints and how to test them properly |
Extend Xentral\LaravelApi\Http\ApiResource with an OA\Schema attribute. Always set additionalProperties: false, specify schema, required, properties, and type.
use Xentral\LaravelApi\Http\ApiResource;
use OpenApi\Attributes as OA;
#[OA\Schema(
schema: 'SalesOrder',
required: ['id', 'status', 'customer', 'createdAt'],
properties: [
new OA\Property(property: 'id', type: 'integer'),
new OA\Property(property: 'status', ref: SalesOrderStatus::class),
new OA\Property(property: 'customer', anyOf: [
new OA\Schema(ref: CustomerResource::class),
new OA\Schema(
properties: [new OA\Property(property: 'id', type: 'integer')],
type: 'object'
),
]),
new OA\Property(property: 'createdAt', type: 'string', format: 'date-time'),
],
type: 'object',
additionalProperties: false,
)]
class SalesOrderResource extends ApiResource
{
/** @var SalesOrder */
public $resource;
public function toArray($request): array
{
return [
'id' => $this->resource->id,
'status' => $this->resource->status,
'customer' => $this->includeWhenLoaded('customer', CustomerResource::class),
'createdAt' => $this->resource->created_at->toAtomString(),
];
}
}
Use the right helper depending on the desired behavior:
// includeWhenLoaded: Full resource when loaded, reference fallback {'id': foreignKey}
'customer' => $this->includeWhenLoaded('customer', CustomerResource::class),
// whenLoaded: Full resource when loaded, custom fallback
'customer' => $this->whenLoaded(
'customer',
fn() => new CustomerResource($this->resource->customer),
['id' => $this->resource->customer_id]
),
// reference: Always returns {'id': foreignKey} or null
'customer' => $this->reference('customer'),
Use DummyInclude when a nested include doesn't map to a real database relationship (e.g., computed fields appended to a relation):
use Xentral\LaravelApi\Query\DummyInclude;
// In controller — DummyInclude::make() auto-loads the parent relationship
$query->allowedIncludes(['customer', 'lineItems', DummyInclude::make('lineItems.customFields')]);
Use deprecatedSince() to add a Sunset HTTP header to responses:
public function view(int $id): SalesOrderResource
{
$order = SalesOrder::findOrFail($id);
return (new SalesOrderResource($order))
->deprecatedSince(new \DateTimeImmutable('2025-06-01'));
}
Use FormRequest or spatie/laravel-data Data objects. Schema required fields and Laravel validation rules must match. Always set additionalProperties: false.
use Illuminate\Foundation\Http\FormRequest;
use OpenApi\Attributes as OA;
#[OA\Schema(
schema: 'CreateSalesOrderRequest',
required: ['customer', 'positions'],
properties: [
new OA\Property(
property: 'customer',
type: 'object',
required: ['id'],
properties: [new OA\Property(property: 'id', type: 'integer')]
),
new OA\Property(
property: 'positions',
type: 'array',
items: new OA\Items(
required: ['sku', 'quantity'],
properties: [
new OA\Property(property: 'sku', type: 'string'),
new OA\Property(property: 'quantity', type: 'integer'),
]
)
),
],
type: 'object',
additionalProperties: false,
)]
class CreateSalesOrderRequest extends FormRequest
{
public function rules(): array
{
return [
'customer.id' => ['required', 'integer', 'exists:customers,id'],
'positions' => ['required', 'array'],
'positions.*.sku' => ['required', 'string'],
'positions.*.quantity' => ['required', 'integer'],
];
}
}
Use #[ListEndpoint] for paginated collection endpoints. Always use QueryBuilder::for() and wrap filters in FilterParameter.
use Xentral\LaravelApi\OpenApi\Endpoints\ListEndpoint;
use Xentral\LaravelApi\OpenApi\Filters\{FilterParameter, IdFilter, StringFilter, DateFilter, EnumFilter};
use Xentral\LaravelApi\OpenApi\{QuerySort, PaginationType};
use Xentral\LaravelApi\Query\{QueryBuilder, Filters\QueryFilter};
#[ListEndpoint(
path: '/api/v1/sales-orders',
resource: SalesOrderResource::class,
description: 'Paginated list of sales orders',
includes: ['customer', 'positions'],
parameters: [
new FilterParameter([
new IdFilter,
new StringFilter(name: 'documentNumber'),
new EnumFilter(name: 'status', enumSource: SalesOrderStatus::class),
new DateFilter(name: 'documentDate'),
new DateFilter(name: 'createdAt'),
new DateFilter(name: 'updatedAt'),
]),
new QuerySort(['created_at', 'updated_at']),
],
paginationType: [PaginationType::SIMPLE, PaginationType::TABLE],
)]
public function index(): ResourceCollection
{
$orders = QueryBuilder::for(SalesOrder::class)
->defaultSort('-created_at')
->allowedFilters([
QueryFilter::identifier(),
QueryFilter::string('documentNumber', 'document_number'),
QueryFilter::string('status', enum: SalesOrderStatus::class),
QueryFilter::date('documentDate', 'datum'),
QueryFilter::date('createdAt', 'created_at'),
QueryFilter::date('updatedAt', 'updated_at'),
])
->allowedSorts(['created_at', 'updated_at'])
->allowedIncludes(['customer', 'positions'])
->apiPaginate(100, PaginationType::SIMPLE, PaginationType::TABLE);
return SalesOrderResource::collection($orders);
}
Use #[GetEndpoint] for single resource retrieval.
use Xentral\LaravelApi\OpenApi\Endpoints\GetEndpoint;
#[GetEndpoint(
path: '/api/v1/sales-orders/{id}',
resource: SalesOrderResource::class,
description: 'View a single sales order',
tags: ['SalesOrder'],
includes: ['customer', 'positions'],
)]
public function view(int $id): SalesOrderResource
{
$order = QueryBuilder::for(SalesOrder::class)
->allowedIncludes(['customer', 'positions'])
->findOrFail($id);
return new SalesOrderResource($order);
}
Use #[PostEndpoint] with successStatus: '201'.
use Xentral\LaravelApi\OpenApi\Endpoints\PostEndpoint;
#[PostEndpoint(
path: '/api/v1/sales-orders',
request: CreateSalesOrderRequest::class,
resource: SalesOrderResource::class,
description: 'Create a new sales order',
tags: ['SalesOrder'],
successStatus: '201',
)]
public function create(CreateSalesOrderRequest $request): SalesOrderResource
{
$order = SalesOrder::create($request->validated());
return new SalesOrderResource($order);
}
Use #[PatchEndpoint] or #[PutEndpoint] for updates.
use Xentral\LaravelApi\OpenApi\Endpoints\PatchEndpoint;
#[PatchEndpoint(
path: '/api/v1/sales-orders/{id}',
request: UpdateSalesOrderRequest::class,
resource: SalesOrderResource::class,
description: 'Update an existing sales order',
tags: ['SalesOrder'],
)]
public function update(UpdateSalesOrderRequest $request, int $id): SalesOrderResource
{
$order = SalesOrder::findOrFail($id);
$order->update($request->validated());
return new SalesOrderResource($order);
}
Use #[DeleteEndpoint] with validates for conditional deletion messages.
use Xentral\LaravelApi\OpenApi\Endpoints\DeleteEndpoint;
#[DeleteEndpoint(
path: '/api/v1/sales-orders/{id}',
description: 'Delete a sales order',
tags: ['SalesOrder'],
validates: ['status' => 'Only pending sales orders can be deleted.'],
)]
public function delete(int $id): Response
{
$order = SalesOrder::findOrFail($id);
if ($order->status !== SalesOrderStatus::PENDING) {
throw ValidationException::withMessages([
'status' => 'Only pending sales orders can be deleted.',
]);
}
$order->delete();
return response()->noContent();
}
Use #[ActionEndpoint] for custom state-changing actions. ActionEndpoint extends PatchEndpoint. Path pattern: /resources/{id}/actions/{action}.
use Xentral\LaravelApi\OpenApi\Endpoints\ActionEndpoint;
#[ActionEndpoint(
path: '/api/v1/sales-orders/{id}/actions/approve',
resource: SalesOrderResource::class,
description: 'Approve a sales order',
tags: ['SalesOrder'],
)]
public function approve(int $id): SalesOrderResource
{
$order = SalesOrder::findOrFail($id);
$order->update(['status' => SalesOrderStatus::APPROVED]);
return new SalesOrderResource($order);
}
Wrap typed filter attributes inside FilterParameter in the endpoint's parameters array.
parameters: [
new FilterParameter([
new IdFilter, // default name: 'id'
new IdFilter(name: 'customerId'), // custom name
new StringFilter(name: 'name'),
new NumberFilter(name: 'totalAmount'),
new DateFilter(name: 'documentDate'),
new DateTimeFilter(name: 'createdAt'),
new EnumFilter(name: 'status', enumSource: StatusEnum::class), // BackedEnum class
new EnumFilter(name: 'type', enumSource: ['a', 'b', 'c']), // array of values
new BooleanFilter(name: 'isActive'),
]),
]
All filter classes live in Xentral\LaravelApi\OpenApi\Filters\ and extend FilterProperty.
| Filter Type | Operators |
|---|---|
IdFilter | equals, notEquals, in, notIn |
StringFilter | equals, notEquals, in, notIn, contains, notContains, startsWith, endsWith |
NumberFilter | equals, notEquals, lessThan, lessThanOrEquals, greaterThan, greaterThanOrEquals |
DateFilter | equals, notEquals, lessThan, lessThanOrEquals, greaterThan, greaterThanOrEquals |
DateTimeFilter | equals, notEquals, lessThan, lessThanOrEquals, greaterThan, greaterThanOrEquals, isNull, isNotNull |
EnumFilter | equals, notEquals, in, notIn |
BooleanFilter | equals, notEquals |
QueryBuilder layer operators (may include additional operators not exposed in OpenAPI):
| QueryFilter Method | Operators |
|---|---|
QueryFilter::identifier() | equals, notEquals, in, notIn, isNull, isNotNull |
QueryFilter::string() | equals, notEquals, in, notIn, contains, notContains, startsWith, endsWith, isNull, isNotNull |
QueryFilter::number() | equals, notEquals, lessThan, lessThanOrEquals, greaterThan, greaterThanOrEquals, isNull, isNotNull |
QueryFilter::date() | equals, notEquals, lessThan, lessThanOrEquals, greaterThan, greaterThanOrEquals, isNull, isNotNull |
QueryFilter::datetime() | equals, notEquals, lessThan, lessThanOrEquals, greaterThan, greaterThanOrEquals, isNull, isNotNull |
QueryFilter::boolean() | equals, notEquals |
QueryFilter::booleanInteger() | equals, notEquals |
Both layers must be declared for every filter. Use this mapping:
| OpenAPI Filter | QueryFilter Method | Notes |
|---|---|---|
IdFilter | QueryFilter::identifier() | Default name 'id' on both sides |
IdFilter(name: 'customerId') | QueryFilter::identifier('customerId', 'customer_id') | Custom name with column mapping |
StringFilter(name: 'name') | QueryFilter::string('name') | |
NumberFilter(name: 'total') | QueryFilter::number('total', 'total_amount') | Second param maps to DB column |
DateFilter(name: 'issuedAt') | QueryFilter::date('issuedAt', 'issued_at') | |
DateTimeFilter(name: 'createdAt') | QueryFilter::datetime('createdAt', 'created_at') | |
EnumFilter(name: 'status', enumSource: E::class) | QueryFilter::string('status', enum: E::class) | Pass enum class to both |
BooleanFilter(name: 'isActive') | QueryFilter::boolean('isActive', 'is_active') |
Use QuerySort in the endpoint attribute and allowedSorts() + defaultSort() on the QueryBuilder.
// OpenAPI layer — in endpoint parameters
parameters: [
new QuerySort(['created_at', 'updated_at', 'total_amount'], default: '-created_at'),
]
// QueryBuilder layer
$query->defaultSort('-created_at')
->allowedSorts(['created_at', 'updated_at', 'total_amount']);
URL format: ?sort=created_at (ascending) or ?sort=-created_at (descending). Multiple sorts: ?sort=-created_at,total_amount.
apiPaginate() Signaturepublic function apiPaginate(
int $maxPageSize = 100,
PaginationType ...$allowedTypes
): Paginator|LengthAwarePaginator|CursorPaginator
| Type | Description | Use Case |
|---|---|---|
PaginationType::SIMPLE | Basic prev/next links | Most efficient, default |
PaginationType::TABLE | Full pagination with page numbers and totals | UI tables with page counts |
PaginationType::CURSOR | Cursor-based pagination | Large/real-time datasets |
?page[size]=25 (max enforced by $maxPageSize parameter)?page[number]=3 (for SIMPLE and TABLE)X-Pagination header (simple, table, cursor) — client selects from the allowed typesschemas.default.config.pagination_response.casing ('snake' or 'camel')Endpoint attribute and apiPaginate() must declare matching types:
// Endpoint attribute
paginationType: [PaginationType::SIMPLE, PaginationType::TABLE, PaginationType::CURSOR],
// Controller
->apiPaginate(100, PaginationType::SIMPLE, PaginationType::TABLE, PaginationType::CURSOR)
Use additionalMediaTypes on GetEndpoint to support multiple response formats:
use Xentral\LaravelApi\OpenApi\Responses\PdfMediaType;
#[GetEndpoint(
path: '/api/v1/invoices/{id}',
resource: InvoiceResource::class,
description: 'Get invoice',
additionalMediaTypes: [new PdfMediaType],
)]
public function show(Request $request, int $id): InvoiceResource|Response
{
$invoice = Invoice::findOrFail($id);
if ($request->header('Accept') === 'application/pdf') {
return new Response($pdfContent, 200, [
'Content-Type' => 'application/pdf',
'Content-Disposition' => 'attachment; filename="invoice.pdf"',
]);
}
return new InvoiceResource($invoice);
}
Pass a DateTimeInterface to the deprecated parameter on any endpoint attribute:
#[GetEndpoint(
path: '/api/v1/legacy-orders/{id}',
resource: OrderResource::class,
description: 'Get order (deprecated)',
deprecated: new \DateTimeImmutable('2025-01-15'),
)]
This marks the endpoint as deprecated: true in the OpenAPI spec and records the deprecation date in the x-deprecated_on extension.
Use deprecatedSince() on ApiResource instances to add a Sunset HTTP header (see Resource Definition section).
// config/openapi.php
'deprecation_filter' => [
'enabled' => true,
'months_before_removal' => 6, // endpoints deprecated > 6 months are removed from spec
],
All endpoint attributes accept these optional parameters:
#[PatchEndpoint(
path: '/api/v1/orders/{id}',
resource: OrderResource::class,
description: 'Update order',
featureFlag: 'order-editing', // string or BackedEnum — adds feature flag notice to description
scopes: ['orders.write', 'admin'], // string or array — recorded in x-scopes extension
problems: ['conflict'], // array — references problems defined in config/openapi.php
isInternal: true, // marks endpoint as internal via x-internal extension
)]
Define reusable problem responses in config/openapi.php:
'problems' => [
'conflict' => [
'status' => 409,
'body' => [
'type' => 'https://api.example.com/problems/conflict',
'title' => 'Conflict happened',
],
],
],
Reuse filters across endpoints by implementing FilterSpecCollection:
use Xentral\LaravelApi\OpenApi\Filters\{FilterProperty, FilterSpecCollection};
#[\Attribute(\Attribute::TARGET_METHOD | \Attribute::IS_REPEATABLE)]
class SalesOrderFilters implements FilterSpecCollection
{
public function getFilterSpecification(): array
{
return [
new IdFilter,
new StringFilter(name: 'documentNumber'),
new DateFilter(name: 'documentDate'),
];
}
}
// Usage in endpoint
parameters: [new FilterParameter([new SalesOrderFilters(), new QuerySort(['created_at'])])]
For the QueryBuilder layer, implement QueryBuilderFilterCollection:
use Xentral\LaravelApi\Query\Filters\QueryBuilderFilterCollection;
class SalesOrderQueryFilters implements QueryBuilderFilterCollection
{
public function getFilters(): array
{
return [
QueryFilter::identifier(),
QueryFilter::string('documentNumber', 'document_number'),
QueryFilter::date('documentDate', 'datum'),
];
}
}
// Usage in controller
$query->allowedFilters(new SalesOrderQueryFilters());
# Single filter
/api/v1/orders?filter[0][key]=documentNumber&filter[0][op]=equals&filter[0][value]=INV-001
# Comparison operator
/api/v1/orders?filter[0][key]=documentDate&filter[0][op]=lessThan&filter[0][value]=2025-05-05
# Multi-value (in operator)
/api/v1/orders?filter[0][key]=status&filter[0][op]=in&filter[0][value][]=pending&filter[0][value][]=approved
# Multiple filters
/api/v1/orders?filter[0][key]=status&filter[0][op]=equals&filter[0][value]=pending&filter[1][key]=documentDate&filter[1][op]=greaterThan&filter[1][value]=2025-01-01
Publish config: php artisan vendor:publish --provider="Xentral\LaravelApi\ApiServiceProvider"
// config/openapi.php
return [
'docs' => [
'enabled' => env('APP_ENV') !== 'production', // enable web docs interface
'prefix' => 'api-docs', // URL prefix
'middleware' => ['web', 'auth'], // middleware for docs routes
'client' => 'swagger', // 'swagger' or 'scalar'
],
'problems' => [
// Reusable problem responses referenced by endpoint 'problems' param
'conflict' => ['status' => 409, 'body' => [...]],
],
'schemas' => [
'default' => [
'client' => null, // per-schema client override
'config' => [
'oas_version' => '3.1.0',
'folders' => [base_path('app')], // directories to scan for attributes
'output' => base_path('openapi.yml'), // output file path
'pagination_response' => [
'casing' => 'snake', // 'snake' or 'camel'
],
'validation_response' => [
'status_code' => 422,
'content_type' => 'application/json',
'max_errors' => 3,
'content' => [
'message' => 'The given data was invalid.',
'errors' => '{{errors}}',
],
],
'deprecation_filter' => [
'enabled' => true,
'months_before_removal' => 6, // auto-remove from spec after N months
],
'feature_flags' => [
'description_prefix' => "This endpoint is only available if the feature flag `{flag}` is enabled.\n\n",
],
'rate_limit_response' => [
'enabled' => true,
'message' => 'Too Many Requests',
],
'validation_commands' => [], // artisan commands to run during validation
],
'info' => [
'name' => 'My API',
'version' => '1.0.0',
'description' => 'Developer API',
'contact' => ['name' => '...', 'url' => '...', 'email' => '...'],
'servers' => [['url' => '...', 'description' => '...']],
],
],
],
];
php artisan openapi:generate # Generate default schema
php artisan openapi:generate v1 # Generate specific schema
Xentral\LaravelApi\Query\QueryBuilder::for(), never plain Eloquentrequired fields must match Laravel validation rulesadditionalProperties: false on schemas/** @var ModelName */ public $resource; for IDE support->toAtomString() for datetime, ->toDateString() for date-onlyincludeWhenLoaded() for relations that should show reference ID when not loadedwhenLoaded() when you need custom loaded/not-loaded behaviorreference() when you only need an identifier objectIdFilter, etc.) must have a matching QueryFilter method — both layers must existFilterParameter([...]), never use the legacy QueryFilter (OpenApi namespace) directlyQueryFilter methods maps API camelCase name to internal DB column namesuccessStatus: '201' for POST endpoints, DELETE returns 204ActionEndpoint at /resources/{id}/actions/{action}PaginationType values in endpoint attribute must match those passed to apiPaginate()php artisan openapi:generate after modifying any OpenAPI attributes