Build WordPress REST API endpoints and AJAX handlers using Pollora WpRestRoute and Ajax attributes with automatic discovery and permission management.
php artisan boost:add-skill pollora/nectar
Save this content to: .claude/skills/pollora-rest-api/SKILL.md
# Pollora REST API Development
## When to use this skill
Use this skill when creating WordPress REST API endpoints using Pollora's attribute-based approach or theme API routes.
## Attribute-Based REST API (WordPress REST)
### Basic Endpoint
```php
<?php
namespace Theme\MyTheme\Cms\Api;
use Pollora\Attributes\WpRestRoute;
use Pollora\Attributes\WpRestRoute\Method;
use Pollora\WpRest\Permissions\Everyone;
use Pollora\WpRest\Permissions\IsLoggedIn;
use WP_REST_Response;
#[WpRestRoute(
namespace: 'my-theme/v1',
route: 'items',
permissionCallback: Everyone::class
)]
class ItemsAPI
{
#[Method('GET')]
public function index(): WP_REST_Response
{
return new WP_REST_Response([
'items' => ['Item 1', 'Item 2'],
]);
}
#[Method('POST', permissionCallback: IsLoggedIn::class)]
public function store(\WP_REST_Request $request): WP_REST_Response
{
$name = $request->get_param('name');
return new WP_REST_Response([
'success' => true,
'item' => $name,
], 201);
}
}
```
Endpoint: `GET /wp-json/my-theme/v1/items`
### Route Parameters
```php
#[WpRestRoute(
namespace: 'my-theme/v1',
route: 'documents/(?P<documentId>\d+)',
permissionCallback: Everyone::class
)]
class DocumentAPI
{
#[Method('GET')]
public function get(int $documentId): WP_REST_Response
{
return new WP_REST_Response([
'documentId' => $documentId,
]);
}
#[Method(['PUT', 'PATCH'], permissionCallback: IsLoggedIn::class)]
public function update(\WP_REST_Request $request, int $documentId): WP_REST_Response
{
return new WP_REST_Response([
'updated' => true,
'documentId' => $documentId,
]);
}
#[Method('DELETE', permissionCallback: IsAdmin::class)]
public function delete(int $documentId): WP_REST_Response
{
return new WP_REST_Response(null, 204);
}
}
```
Endpoint: `GET /wp-json/my-theme/v1/documents/18`
### Permissions
Always declare a permission. A route without one is public, and `pollora/wp-rest` 0.2 warns about it (`_doing_it_wrong`); the next major version refuses it. Use `Everyone::class` for a route that is public on purpose, a method's permission replaces its route's, or create a custom class:
```php
use Pollora\WpRest\Permissions\Can;
use Pollora\WpRest\Permissions\Everyone;
use Pollora\WpRest\Permissions\IsAdmin;
use Pollora\WpRest\Permissions\IsLoggedIn;
// Built-in permissions
#[WpRestRoute(
namespace: 'my-theme/v1',
route: 'admin/settings',
permissionCallback: IsAdmin::class
)]
class SettingsAPI {}
// A capability (v13.34.5+): a string or a #[CapabilitySet] enum case
#[WpRestRoute(namespace: 'my-theme/v1', route: 'reports', permissionCallback: new Can('export_attendees'))]
class ReportsAPI {}
// A meta capability on the object in the request: edit_post with the `id` parameter
#[Method('PUT', permissionCallback: new Can('edit_post', parameter: 'id'))]
public function update(int $id): array {}
// Custom permission
use Pollora\Attributes\WpRestRoute\Permission;
class CanManageBooks implements Permission
{
public function allow(\WP_REST_Request $request): bool|\WP_Error
{
return current_user_can('edit_books')
? true
: new \WP_Error('forbidden', __('You cannot manage books.'), ['status' => 403]);
}
}
#[WpRestRoute(
namespace: 'my-theme/v1',
route: 'books',
permissionCallback: CanManageBooks::class
)]
class BooksAPI {}
```
A `Can` refusal answers 401 to a guest and 403 to a logged-in user. Prefer `Can` to a custom class that only calls `current_user_can()` (see the `pollora-roles` skill).
## Theme API Routes (Lightweight Mode)
For performance-critical API endpoints, themes can define routes that load WordPress **without plugins** (~100ms vs ~1.3s):
```php
// themes/my-theme/routes/api.php
use Illuminate\Support\Facades\Route;
Route::get('/products/search', [ProductSearchController::class, 'search']);
Route::get('/products/{id}', [ProductSearchController::class, 'show']);
```
Endpoint: `GET /api/products/search`
### Plugin Loading Control
```php
// config/wordpress.php
'api_plugins' => [], // No plugins (fastest, default)
'api_plugins' => ['woocommerce', 'acf-pro'], // Selective loading
'api_plugins' => ['*'], // All plugins
```
## AJAX Handlers (admin-ajax.php)
For `admin-ajax.php` actions, put `#[Ajax]` on a public method. The class is discovered and built through the container. Handlers are **logged-in users only** unless `access` says otherwise.
```php
use Pollora\Attributes\Ajax;
use Pollora\Ajax\Domain\Model\AjaxAccess;
class NewsletterHandler
{
#[Ajax('subscribe')] // wp_ajax_subscribe (logged in)
public function subscribe(): void
{
check_ajax_referer('newsletter');
wp_send_json_success(['message' => 'Subscribed!']);
}
#[Ajax('load_more', access: AjaxAccess::ALL)] // + wp_ajax_nopriv_load_more
public function loadMore(): void
{
wp_send_json_success([]);
}
}
```
`AjaxAccess::LOGGED` (default), `AjaxAccess::ALL`, `AjaxAccess::GUEST`. The imperative form is `Ajax::listen('action', $handler)->forAllUsers()` / `->forGuestUsers()`.
## Choosing Between Approaches
| Feature | WpRestRoute Attributes | Theme API Routes |
|---------|----------------------|------------------|
| WordPress REST API | Yes (`/wp-json/...`) | No (`/api/...`) |
| Plugin access | Full WordPress context | Configurable (none/selective/all) |
| Performance | Standard (~1.3s) | Fast (~100ms without plugins) |
| Authentication | WordPress nonces/cookies | Laravel middleware |
| Discovery | Auto-discovered | File-based (`routes/api.php`) |
## Important Notes
- REST API classes are auto-discovered — no manual `register_rest_route()` needed
- Route parameters use WordPress regex syntax: `(?P<paramName>\d+)`
- Method parameters are auto-injected from route parameters by name
- The `WP_REST_Request` object is available via type-hint
- Run `php artisan discovery:clear` after adding new REST API classes
- Use theme API routes when you need maximum performance and don't need full WordPress context
Use this skill when creating WordPress REST API endpoints using Pollora's attribute-based approach or theme API routes.
<?php
namespace Theme\MyTheme\Cms\Api;
use Pollora\Attributes\WpRestRoute;
use Pollora\Attributes\WpRestRoute\Method;
use Pollora\WpRest\Permissions\Everyone;
use Pollora\WpRest\Permissions\IsLoggedIn;
use WP_REST_Response;
#[WpRestRoute(
namespace: 'my-theme/v1',
route: 'items',
permissionCallback: Everyone::class
)]
class ItemsAPI
{
#[Method('GET')]
public function index(): WP_REST_Response
{
return new WP_REST_Response([
'items' => ['Item 1', 'Item 2'],
]);
}
#[Method('POST', permissionCallback: IsLoggedIn::class)]
public function store(\WP_REST_Request $request): WP_REST_Response
{
$name = $request->get_param('name');
return new WP_REST_Response([
'success' => true,
'item' => $name,
], 201);
}
}
Endpoint: GET /wp-json/my-theme/v1/items
#[WpRestRoute(
namespace: 'my-theme/v1',
route: 'documents/(?P<documentId>\d+)',
permissionCallback: Everyone::class
)]
class DocumentAPI
{
#[Method('GET')]
public function get(int $documentId): WP_REST_Response
{
return new WP_REST_Response([
'documentId' => $documentId,
]);
}
#[Method(['PUT', 'PATCH'], permissionCallback: IsLoggedIn::class)]
public function update(\WP_REST_Request $request, int $documentId): WP_REST_Response
{
return new WP_REST_Response([
'updated' => true,
'documentId' => $documentId,
]);
}
#[Method('DELETE', permissionCallback: IsAdmin::class)]
public function delete(int $documentId): WP_REST_Response
{
return new WP_REST_Response(null, 204);
}
}
Endpoint: GET /wp-json/my-theme/v1/documents/18
Always declare a permission. A route without one is public, and pollora/wp-rest 0.2 warns about it (_doing_it_wrong); the next major version refuses it. Use Everyone::class for a route that is public on purpose, a method's permission replaces its route's, or create a custom class:
use Pollora\WpRest\Permissions\Can;
use Pollora\WpRest\Permissions\Everyone;
use Pollora\WpRest\Permissions\IsAdmin;
use Pollora\WpRest\Permissions\IsLoggedIn;
// Built-in permissions
#[WpRestRoute(
namespace: 'my-theme/v1',
route: 'admin/settings',
permissionCallback: IsAdmin::class
)]
class SettingsAPI {}
// A capability (v13.34.5+): a string or a #[CapabilitySet] enum case
#[WpRestRoute(namespace: 'my-theme/v1', route: 'reports', permissionCallback: new Can('export_attendees'))]
class ReportsAPI {}
// A meta capability on the object in the request: edit_post with the `id` parameter
#[Method('PUT', permissionCallback: new Can('edit_post', parameter: 'id'))]
public function update(int $id): array {}
// Custom permission
use Pollora\Attributes\WpRestRoute\Permission;
class CanManageBooks implements Permission
{
public function allow(\WP_REST_Request $request): bool|\WP_Error
{
return current_user_can('edit_books')
? true
: new \WP_Error('forbidden', __('You cannot manage books.'), ['status' => 403]);
}
}
#[WpRestRoute(
namespace: 'my-theme/v1',
route: 'books',
permissionCallback: CanManageBooks::class
)]
class BooksAPI {}
A Can refusal answers 401 to a guest and 403 to a logged-in user. Prefer Can to a custom class that only calls current_user_can() (see the pollora-roles skill).
For performance-critical API endpoints, themes can define routes that load WordPress without plugins (~100ms vs ~1.3s):
// themes/my-theme/routes/api.php
use Illuminate\Support\Facades\Route;
Route::get('/products/search', [ProductSearchController::class, 'search']);
Route::get('/products/{id}', [ProductSearchController::class, 'show']);
Endpoint: GET /api/products/search
// config/wordpress.php
'api_plugins' => [], // No plugins (fastest, default)
'api_plugins' => ['woocommerce', 'acf-pro'], // Selective loading
'api_plugins' => ['*'], // All plugins
For admin-ajax.php actions, put #[Ajax] on a public method. The class is discovered and built through the container. Handlers are logged-in users only unless access says otherwise.
use Pollora\Attributes\Ajax;
use Pollora\Ajax\Domain\Model\AjaxAccess;
class NewsletterHandler
{
#[Ajax('subscribe')] // wp_ajax_subscribe (logged in)
public function subscribe(): void
{
check_ajax_referer('newsletter');
wp_send_json_success(['message' => 'Subscribed!']);
}
#[Ajax('load_more', access: AjaxAccess::ALL)] // + wp_ajax_nopriv_load_more
public function loadMore(): void
{
wp_send_json_success([]);
}
}
AjaxAccess::LOGGED (default), AjaxAccess::ALL, AjaxAccess::GUEST. The imperative form is Ajax::listen('action', $handler)->forAllUsers() / ->forGuestUsers().
| Feature | WpRestRoute Attributes | Theme API Routes |
|---|---|---|
| WordPress REST API | Yes (/wp-json/...) |
No (/api/...) |
| Plugin access | Full WordPress context | Configurable (none/selective/all) |
| Performance | Standard (~1.3s) | Fast (~100ms without plugins) |
| Authentication | WordPress nonces/cookies | Laravel middleware |
| Discovery | Auto-discovered | File-based (routes/api.php) |
register_rest_route() needed(?P<paramName>\d+)WP_REST_Request object is available via type-hintphp artisan discovery:clear after adding new REST API classesHow can I help you explore Laravel packages today?