---
url: /foehn-framework/guide/rest-api.md
---
# REST API

Føhn provides `#[AsRestRoute]` for creating REST API endpoints declaratively.

::: tip Why REST API over admin-ajax.php?
The REST API is the recommended approach for AJAX in WordPress since version 4.7 (2016). Compared to the legacy `admin-ajax.php`:

* **Proper HTTP methods** (GET, POST, PUT, DELETE) with semantic meaning
* **Built-in authentication** via nonces, cookies, or application passwords
* **Cacheable** — GET requests can be cached by plugins like WP Rocket
* **Standardized responses** with proper HTTP status codes
* **Schema validation** for request parameters
* **Better for headless/external consumers**

The `admin-ajax.php` endpoint should be considered legacy. Use `permission: 'public'` for endpoints accessible to non-logged-in users (equivalent to `wp_ajax_nopriv_` hooks).
:::

## Basic Endpoint

```php
<?php
// app/Rest/ProductsApi.php

namespace App\Rest;

use Studiometa\Foehn\Attributes\AsRestRoute;
use WP_REST_Request;
use WP_REST_Response;

final class ProductsApi
{
    #[AsRestRoute(
        namespace: 'theme/v1',
        route: '/products',
        method: 'GET',
    )]
    public function list(WP_REST_Request $request): WP_REST_Response
    {
        $products = \Timber\Timber::get_posts([
            'post_type' => 'product',
            'posts_per_page' => $request->get_param('per_page') ?? 10,
            'paged' => $request->get_param('page') ?? 1,
        ]);

        $data = array_map(fn($product) => [
            'id' => $product->ID,
            'title' => $product->title,
            'price' => $product->price(),
            'link' => $product->link(),
        ], $products);

        return new WP_REST_Response($data);
    }
}
```

**Endpoint:** `GET /wp-json/theme/v1/products`

## Route Parameters

```php
#[AsRestRoute(
    namespace: 'theme/v1',
    route: '/products/(?P<id>\d+)',
    method: 'GET',
)]
public function show(WP_REST_Request $request): WP_REST_Response
{
    $id = (int) $request->get_param('id');
    $product = \Timber\Timber::get_post($id);

    if (!$product || $product->post_type !== 'product') {
        return new WP_REST_Response(['error' => 'Product not found'], 404);
    }

    return new WP_REST_Response([
        'id' => $product->ID,
        'title' => $product->title,
        'content' => $product->content(),
        'price' => $product->price(),
    ]);
}
```

**Endpoint:** `GET /wp-json/theme/v1/products/123`

## HTTP Methods

```php
// GET request
#[AsRestRoute(namespace: 'theme/v1', route: '/items', method: 'GET')]
public function list(WP_REST_Request $request): WP_REST_Response {}

// POST request
#[AsRestRoute(namespace: 'theme/v1', route: '/items', method: 'POST')]
public function create(WP_REST_Request $request): WP_REST_Response {}

// PUT request
#[AsRestRoute(namespace: 'theme/v1', route: '/items/(?P<id>\d+)', method: 'PUT')]
public function update(WP_REST_Request $request): WP_REST_Response {}

// PATCH request
#[AsRestRoute(namespace: 'theme/v1', route: '/items/(?P<id>\d+)', method: 'PATCH')]
public function patch(WP_REST_Request $request): WP_REST_Response {}

// DELETE request
#[AsRestRoute(namespace: 'theme/v1', route: '/items/(?P<id>\d+)', method: 'DELETE')]
public function delete(WP_REST_Request $request): WP_REST_Response {}
```

## Permission Callbacks

### Public Endpoints

```php
#[AsRestRoute(
    namespace: 'theme/v1',
    route: '/products',
    method: 'GET',
    permission: 'public',
)]
public function list(WP_REST_Request $request): WP_REST_Response
{
    // Anyone can access
}
```

### Default Permission (edit\_posts)

When no `permission` is specified, routes require the `edit_posts` capability by default:

```php
#[AsRestRoute(
    namespace: 'theme/v1',
    route: '/drafts',
    method: 'GET',
)]
public function listDrafts(WP_REST_Request $request): WP_REST_Response
{
    // Requires edit_posts capability (default)
}
```

This default can be changed via configuration:

```php
// functions.php
Kernel::boot(__DIR__, [
    'rest_default_capability' => 'manage_options', // Require admin for all routes
]);

// Or use null to only require authentication (is_user_logged_in)
Kernel::boot(__DIR__, [
    'rest_default_capability' => null,
]);
```

### Custom Permission Callbacks

```php
#[AsRestRoute(
    namespace: 'theme/v1',
    route: '/orders',
    method: 'GET',
    permission: 'canViewOrders',
)]
public function listOrders(WP_REST_Request $request): WP_REST_Response
{
    // Only users with permission
}

public function canViewOrders(WP_REST_Request $request): bool
{
    return current_user_can('read');
}
```

### Admin-Only Endpoints

```php
#[AsRestRoute(
    namespace: 'theme/v1',
    route: '/admin/settings',
    method: 'POST',
    permission: 'isAdmin',
)]
public function updateSettings(WP_REST_Request $request): WP_REST_Response {}

public function isAdmin(): bool
{
    return current_user_can('manage_options');
}
```

## Request Arguments Schema

Define and validate request parameters:

```php
#[AsRestRoute(
    namespace: 'theme/v1',
    route: '/products',
    method: 'GET',
    args: [
        'per_page' => [
            'type' => 'integer',
            'default' => 10,
            'minimum' => 1,
            'maximum' => 100,
        ],
        'page' => [
            'type' => 'integer',
            'default' => 1,
            'minimum' => 1,
        ],
        'category' => [
            'type' => 'string',
            'sanitize_callback' => 'sanitize_text_field',
        ],
        'orderby' => [
            'type' => 'string',
            'default' => 'date',
            'enum' => ['date', 'title', 'price', 'popularity'],
        ],
    ],
)]
public function list(WP_REST_Request $request): WP_REST_Response
{
    $perPage = $request->get_param('per_page');
    $page = $request->get_param('page');
    $category = $request->get_param('category');
    $orderby = $request->get_param('orderby');

    // Parameters are validated automatically
}
```

## Full CRUD Example

```php
<?php
// app/Rest/ProductsApi.php

namespace App\Rest;

use Studiometa\Foehn\Attributes\AsRestRoute;
use WP_REST_Request;
use WP_REST_Response;

final class ProductsApi
{
    #[AsRestRoute(
        namespace: 'theme/v1',
        route: '/products',
        method: 'GET',
        permission: 'public',
        args: [
            'per_page' => ['type' => 'integer', 'default' => 10],
            'page' => ['type' => 'integer', 'default' => 1],
        ],
    )]
    public function index(WP_REST_Request $request): WP_REST_Response
    {
        $products = get_posts([
            'post_type' => 'product',
            'posts_per_page' => $request->get_param('per_page'),
            'paged' => $request->get_param('page'),
        ]);

        return new WP_REST_Response(
            array_map([$this, 'formatProduct'], $products)
        );
    }

    #[AsRestRoute(
        namespace: 'theme/v1',
        route: '/products/(?P<id>\d+)',
        method: 'GET',
        permission: 'public',
    )]
    public function show(WP_REST_Request $request): WP_REST_Response
    {
        $product = get_post($request->get_param('id'));

        if (!$product || $product->post_type !== 'product') {
            return new WP_REST_Response(['error' => 'Not found'], 404);
        }

        return new WP_REST_Response($this->formatProduct($product));
    }

    #[AsRestRoute(
        namespace: 'theme/v1',
        route: '/products',
        method: 'POST',
        permission: 'canCreateProduct',
        args: [
            'title' => ['type' => 'string', 'required' => true],
            'content' => ['type' => 'string', 'default' => ''],
            'price' => ['type' => 'number', 'required' => true],
        ],
    )]
    public function store(WP_REST_Request $request): WP_REST_Response
    {
        $id = wp_insert_post([
            'post_type' => 'product',
            'post_title' => $request->get_param('title'),
            'post_content' => $request->get_param('content'),
            'post_status' => 'publish',
        ]);

        if (is_wp_error($id)) {
            return new WP_REST_Response(['error' => $id->get_error_message()], 400);
        }

        update_post_meta($id, 'price', $request->get_param('price'));

        return new WP_REST_Response(
            $this->formatProduct(get_post($id)),
            201
        );
    }

    #[AsRestRoute(
        namespace: 'theme/v1',
        route: '/products/(?P<id>\d+)',
        method: 'PUT',
        permission: 'canEditProduct',
    )]
    public function update(WP_REST_Request $request): WP_REST_Response
    {
        $id = (int) $request->get_param('id');

        wp_update_post([
            'ID' => $id,
            'post_title' => $request->get_param('title'),
            'post_content' => $request->get_param('content'),
        ]);

        if ($request->has_param('price')) {
            update_post_meta($id, 'price', $request->get_param('price'));
        }

        return new WP_REST_Response($this->formatProduct(get_post($id)));
    }

    #[AsRestRoute(
        namespace: 'theme/v1',
        route: '/products/(?P<id>\d+)',
        method: 'DELETE',
        permission: 'canDeleteProduct',
    )]
    public function destroy(WP_REST_Request $request): WP_REST_Response
    {
        $id = (int) $request->get_param('id');
        wp_delete_post($id, true);

        return new WP_REST_Response(null, 204);
    }

    // Permission callbacks
    public function canCreateProduct(): bool
    {
        return current_user_can('publish_posts');
    }

    public function canEditProduct(WP_REST_Request $request): bool
    {
        return current_user_can('edit_post', $request->get_param('id'));
    }

    public function canDeleteProduct(WP_REST_Request $request): bool
    {
        return current_user_can('delete_post', $request->get_param('id'));
    }

    private function formatProduct(\WP_Post $product): array
    {
        return [
            'id' => $product->ID,
            'title' => $product->post_title,
            'content' => $product->post_content,
            'price' => (float) get_post_meta($product->ID, 'price', true),
            'link' => get_permalink($product->ID),
        ];
    }
}
```

## Dependency Injection

```php
<?php

namespace App\Rest;

use App\Services\ProductService;
use Studiometa\Foehn\Attributes\AsRestRoute;
use WP_REST_Request;
use WP_REST_Response;

final class ProductsApi
{
    public function __construct(
        private readonly ProductService $products,
    ) {}

    #[AsRestRoute(namespace: 'theme/v1', route: '/products', method: 'GET')]
    public function list(WP_REST_Request $request): WP_REST_Response
    {
        return new WP_REST_Response(
            $this->products->getAll($request->get_params())
        );
    }
}
```

## Attribute Parameters

| Parameter    | Type      | Default    | Description                       |
| ------------ | --------- | ---------- | --------------------------------- |
| `namespace`  | `string`  | *required* | REST namespace (e.g., `theme/v1`) |
| `route`      | `string`  | *required* | Route pattern                     |
| `method`     | `string`  | `'GET'`    | HTTP method                       |
| `permission` | `?string` | `null`     | Permission callback or `'public'` |
| `args`       | `array`   | `[]`       | Request arguments schema          |

## Returning HTML

REST endpoints can return HTML for AJAX partial loading (e.g., "load more" buttons, infinite scroll):

```php
<?php

namespace App\Rest;

use Studiometa\Foehn\Attributes\AsRestRoute;
use Studiometa\Foehn\Contracts\ViewEngineInterface;
use WP_REST_Request;
use WP_REST_Response;

final class PartialsApi
{
    public function __construct(
        private readonly ViewEngineInterface $view,
    ) {}

    #[AsRestRoute(
        namespace: 'theme/v1',
        route: '/partials/posts',
        method: 'GET',
        permission: 'public',
        args: [
            'page' => ['type' => 'integer', 'default' => 1],
            'per_page' => ['type' => 'integer', 'default' => 6],
        ],
    )]
    public function loadMorePosts(WP_REST_Request $request): WP_REST_Response
    {
        $posts = \Timber\Timber::get_posts([
            'post_type' => 'post',
            'posts_per_page' => $request->get_param('per_page'),
            'paged' => $request->get_param('page'),
        ]);

        $html = '';
        foreach ($posts as $post) {
            $html .= $this->view->render('partials/card', ['post' => $post]);
        }

        return new WP_REST_Response([
            'html' => $html,
            'hasMore' => count($posts) === $request->get_param('per_page'),
        ]);
    }
}
```

**Frontend usage:**

```js
async function loadMore(page) {
  const response = await fetch(`/wp-json/theme/v1/partials/posts?page=${page}`);
  const { html, hasMore } = await response.json();

  document.querySelector(".posts-grid").insertAdjacentHTML("beforeend", html);

  if (!hasMore) {
    document.querySelector(".load-more-btn").remove();
  }
}
```

::: tip Caching HTML responses
Public GET endpoints returning HTML can be cached by WP Rocket and similar plugins, improving performance for repeated requests.
:::

## See Also

* [API Reference: #\[AsRestRoute\]](/api/as-rest-route)
