Back to blog

Yii2 RESTful API Development

yii2phprest-apibackendapi
Yii2 RESTful API Development

Phase 4 and 5 gave your task manager authentication, RBAC, and a complete form system. But forms serve browsers. Mobile apps, SPAs, third-party integrations — they all talk REST. And building REST APIs from scratch is tedious: routing, serialization, pagination, error formatting, authentication, rate limiting, CORS. Yii2 ships all of this out of the box.

With ActiveController, you get a fully functional CRUD API in under 20 lines of code. With yii\rest\Controller, you get full control when the defaults aren't enough. Both inherit Yii2's authentication filters, rate limiting, and content negotiation automatically.

By the end of this post, your task manager will have a complete REST API with token authentication, controlled field exposure, pagination, rate limiting, versioning, and proper CORS headers.

What You'll Build

ActiveController for instant CRUD APIs with zero boilerplate
✅ Custom REST controllers for non-standard endpoints
✅ Field serialization with fields() and extraFields()
✅ Pagination, sorting, and filtering
✅ Bearer token authentication with HttpBearerAuth
✅ Rate limiting with RateLimiterInterface
✅ API versioning (URL prefix strategy)
✅ CORS configuration for cross-origin requests


1. Your First REST API in 5 Minutes

Configure URL Manager for Pretty URLs

REST APIs need clean URLs like /api/tasks/42 instead of ?r=task/view&id=42. Add REST-specific URL rules:

// config/web.php
'components' => [
    'urlManager' => [
        'enablePrettyUrl' => true,
        'showScriptName' => false,
        'enableStrictParsing' => false,
        'rules' => [
            // REST rules for tasks
            ['class' => 'yii\rest\UrlRule', 'controller' => 'api/task'],
        ],
    ],
    'request' => [
        'parsers' => [
            'application/json' => 'yii\web\JsonParser',
        ],
    ],
],

The JsonParser is critical — without it, Yii2 can't read JSON request bodies (Content-Type: application/json).

What yii\rest\UrlRule Generates

A single line creates all standard REST routes:

HTTP MethodURLActionDescription
GET/api/tasksindexList all tasks
GET/api/tasks/42viewGet task #42
POST/api/taskscreateCreate a task
PUT/api/tasks/42updateUpdate task #42
PATCH/api/tasks/42updatePartial update task #42
DELETE/api/tasks/42deleteDelete task #42
OPTIONS/api/tasksoptionsCORS preflight
HEAD/api/tasksindexHeaders only

Note how it pluralizes tasktasks in the URL automatically.

Create the Controller

// controllers/api/TaskController.php
namespace app\controllers\api;
 
use yii\rest\ActiveController;
 
class TaskController extends ActiveController
{
    public $modelClass = 'app\models\Task';
}

That's it. Two lines of meaningful code and you have a fully functional CRUD API. ActiveController provides index, view, create, update, and delete actions — all wired to your Task Active Record model.

Test It

# List all tasks
curl -s http://localhost:8080/api/tasks | jq
 
# Create a task
curl -s -X POST http://localhost:8080/api/tasks \
  -H "Content-Type: application/json" \
  -d '{"title": "Learn Yii2 REST", "status": 1}' | jq
 
# Get a single task
curl -s http://localhost:8080/api/tasks/1 | jq
 
# Update a task
curl -s -X PUT http://localhost:8080/api/tasks/1 \
  -H "Content-Type: application/json" \
  -d '{"status": 3}' | jq
 
# Delete a task
curl -s -X DELETE http://localhost:8080/api/tasks/1
# Returns 204 No Content

Default Response Format

ActiveController automatically returns JSON:

// GET /api/tasks
[
    {
        "id": 1,
        "title": "Learn Yii2 REST",
        "description": null,
        "status": 1,
        "priority": 2,
        "user_id": 1,
        "created_at": "2026-03-18 10:30:00",
        "updated_at": "2026-03-18 10:30:00"
    }
]

Problem: this exposes every column, including user_id, created_at, and updated_at. We'll fix that with serialization in section 3.


2. Custom REST Controllers

ActiveController is great for standard CRUD, but real APIs need custom actions — search, bulk operations, statistics, actions that span multiple models. For these, extend yii\rest\Controller directly.

Disabling Default Actions

First, let's customize ActiveController by disabling or overriding actions:

// controllers/api/TaskController.php
namespace app\controllers\api;
 
use yii\rest\ActiveController;
 
class TaskController extends ActiveController
{
    public $modelClass = 'app\models\Task';
 
    public function actions()
    {
        $actions = parent::actions();
 
        // Remove delete action — we soft-delete instead
        unset($actions['delete']);
 
        // Customize the index action's query
        $actions['index']['prepareDataProvider'] = [$this, 'prepareDataProvider'];
 
        return $actions;
    }
 
    public function prepareDataProvider()
    {
        $query = \app\models\Task::find()
            ->where(['user_id' => \Yii::$app->user->id])
            ->andWhere(['!=', 'status', \app\models\Task::STATUS_DELETED]);
 
        return new \yii\data\ActiveDataProvider([
            'query' => $query,
            'sort' => [
                'defaultOrder' => ['created_at' => SORT_DESC],
            ],
            'pagination' => [
                'pageSize' => 20,
            ],
        ]);
    }
}

Adding Custom Actions

class TaskController extends ActiveController
{
    public $modelClass = 'app\models\Task';
 
    public function actions()
    {
        $actions = parent::actions();
        unset($actions['delete']);
        return $actions;
    }
 
    /**
     * POST /api/tasks/42/complete
     */
    public function actionComplete($id)
    {
        $task = $this->findModel($id);
        $task->status = Task::STATUS_DONE;
        $task->completed_at = date('Y-m-d H:i:s');
 
        if ($task->save()) {
            return $task;
        }
 
        return $task; // validation errors auto-serialized
    }
 
    /**
     * GET /api/tasks/stats
     */
    public function actionStats()
    {
        $userId = \Yii::$app->user->id;
 
        return [
            'total' => Task::find()->where(['user_id' => $userId])->count(),
            'todo' => Task::find()->where([
                'user_id' => $userId,
                'status' => Task::STATUS_TODO,
            ])->count(),
            'in_progress' => Task::find()->where([
                'user_id' => $userId,
                'status' => Task::STATUS_IN_PROGRESS,
            ])->count(),
            'done' => Task::find()->where([
                'user_id' => $userId,
                'status' => Task::STATUS_DONE,
            ])->count(),
        ];
    }
 
    /**
     * POST /api/tasks/bulk-update
     */
    public function actionBulkUpdate()
    {
        $request = \Yii::$app->request;
        $ids = $request->post('ids', []);
        $status = $request->post('status');
 
        if (empty($ids) || $status === null) {
            throw new \yii\web\BadRequestHttpException(
                'ids and status are required.'
            );
        }
 
        $count = Task::updateAll(
            ['status' => $status],
            ['id' => $ids, 'user_id' => \Yii::$app->user->id]
        );
 
        return ['updated' => $count];
    }
 
    protected function findModel($id)
    {
        $model = Task::findOne([
            'id' => $id,
            'user_id' => \Yii::$app->user->id,
        ]);
 
        if ($model === null) {
            throw new \yii\web\NotFoundHttpException("Task #$id not found.");
        }
 
        return $model;
    }
}

Custom URL Rules for Non-Standard Actions

Custom actions need explicit URL rules:

// config/web.php
'rules' => [
    // Custom actions first (order matters — first match wins)
    'POST api/tasks/<id:\d+>/complete' => 'api/task/complete',
    'GET api/tasks/stats' => 'api/task/stats',
    'POST api/tasks/bulk-update' => 'api/task/bulk-update',
 
    // Standard REST rules last
    ['class' => 'yii\rest\UrlRule', 'controller' => 'api/task'],
],

Building from yii\rest\Controller

When you don't want any default CRUD actions:

// controllers/api/AuthController.php
namespace app\controllers\api;
 
use yii\rest\Controller;
 
class AuthController extends Controller
{
    /**
     * No authentication needed for login/register.
     */
    public function behaviors()
    {
        $behaviors = parent::behaviors();
        unset($behaviors['authenticator']);
        return $behaviors;
    }
 
    /**
     * POST /api/auth/login
     */
    public function actionLogin()
    {
        $request = \Yii::$app->request;
        $email = $request->post('email');
        $password = $request->post('password');
 
        $user = \app\models\User::findOne(['email' => $email]);
 
        if (!$user || !$user->validatePassword($password)) {
            throw new \yii\web\UnauthorizedHttpException(
                'Invalid email or password.'
            );
        }
 
        // Generate new access token
        $user->access_token = \Yii::$app->security->generateRandomString(32);
        $user->save(false);
 
        return [
            'token' => $user->access_token,
            'user' => [
                'id' => $user->id,
                'username' => $user->username,
                'email' => $user->email,
            ],
        ];
    }
 
    /**
     * POST /api/auth/register
     */
    public function actionRegister()
    {
        $model = new \app\models\User();
        $model->scenario = \app\models\User::SCENARIO_REGISTER;
 
        if ($model->load(\Yii::$app->request->post(), '') && $model->validate()) {
            $model->setPassword($model->password);
            $model->access_token = \Yii::$app->security->generateRandomString(32);
            $model->save(false);
 
            \Yii::$app->response->statusCode = 201;
            return [
                'token' => $model->access_token,
                'user' => [
                    'id' => $model->id,
                    'username' => $model->username,
                    'email' => $model->email,
                ],
            ];
        }
 
        return $model;
    }
}

3. Serialization — Controlling API Output

By default, Yii2 serializes every model attribute. In production, you want to control exactly which fields are exposed and allow clients to request related data on demand.

fields() — Default Fields

Override fields() on your model to define the default set of fields returned:

// models/Task.php
class Task extends ActiveRecord
{
    public function fields()
    {
        return [
            'id',
            'title',
            'description',
            'status',
            'status_label' => function ($model) {
                return $model->getStatusLabel();
            },
            'priority',
            'due_date',
        ];
    }
}

Now GET /api/tasks/1 returns:

{
    "id": 1,
    "title": "Learn Yii2 REST",
    "description": "Build a complete REST API",
    "status": 1,
    "status_label": "To Do",
    "priority": 2,
    "due_date": "2026-04-01"
}

Notice: user_id, created_at, and updated_at are hidden. status_label is a computed field.

extraFields() — Expandable Relations

extraFields() defines fields that clients can opt into via the expand query parameter:

// models/Task.php
class Task extends ActiveRecord
{
    public function fields()
    {
        return ['id', 'title', 'description', 'status', 'priority', 'due_date'];
    }
 
    public function extraFields()
    {
        return ['user', 'comments', 'attachments'];
    }
 
    public function getUser()
    {
        return $this->hasOne(User::class, ['id' => 'user_id']);
    }
 
    public function getComments()
    {
        return $this->hasMany(Comment::class, ['task_id' => 'id']);
    }
 
    public function getAttachments()
    {
        return $this->hasMany(Attachment::class, ['task_id' => 'id']);
    }
}
# Default — no relations
GET /api/tasks/1
 
# Expand user info
GET /api/tasks/1?expand=user
 
# Expand multiple relations
GET /api/tasks/1?expand=user,comments
 
# Select specific fields + expand
GET /api/tasks/1?fields=id,title,status&expand=user

Response with ?expand=user:

{
    "id": 1,
    "title": "Learn Yii2 REST",
    "description": "Build a complete REST API",
    "status": 1,
    "priority": 2,
    "due_date": "2026-04-01",
    "user": {
        "id": 1,
        "username": "chanh",
        "email": "chanh@example.com"
    }
}

Control User Serialization Too

The User model should also control its output:

// models/User.php
class User extends ActiveRecord
{
    public function fields()
    {
        return [
            'id',
            'username',
            'email',
        ];
        // password_hash, access_token, etc. are NEVER exposed
    }
}

The Serializer Component

Yii2's REST serializer handles the conversion from models/data providers to arrays. You can customize it:

// controllers/api/TaskController.php
class TaskController extends ActiveController
{
    public $modelClass = 'app\models\Task';
 
    // Use ArraySerializer for flat collections (no _meta wrapper)
    public $serializer = [
        'class' => 'yii\rest\Serializer',
        'collectionEnvelope' => 'items',
        'metaEnvelope' => 'meta',
    ];
}

Default serializer response for collections:

// Without envelope (default)
[
    {"id": 1, "title": "Task 1"},
    {"id": 2, "title": "Task 2"}
]
// Pagination info is in HTTP headers (X-Pagination-*)
 
// With collectionEnvelope
{
    "items": [
        {"id": 1, "title": "Task 1"},
        {"id": 2, "title": "Task 2"}
    ],
    "meta": {
        "totalCount": 42,
        "pageCount": 3,
        "currentPage": 1,
        "perPage": 20
    }
}

4. Pagination, Sorting & Filtering

Automatic Pagination

ActiveController::actionIndex() returns an ActiveDataProvider, which automatically handles pagination. Clients control it via query parameters:

# Page 2, 10 items per page
GET /api/tasks?page=2&per-page=10

Pagination info appears in response headers:

X-Pagination-Total-Count: 42
X-Pagination-Page-Count: 5
X-Pagination-Current-Page: 2
X-Pagination-Per-Page: 10
Link: <http://localhost/api/tasks?page=1&per-page=10>; rel="first",
      <http://localhost/api/tasks?page=5&per-page=10>; rel="last",
      <http://localhost/api/tasks?page=1&per-page=10>; rel="prev",
      <http://localhost/api/tasks?page=3&per-page=10>; rel="next"

Sorting

Enable sorting in the data provider:

public function prepareDataProvider()
{
    return new \yii\data\ActiveDataProvider([
        'query' => Task::find()->where(['user_id' => \Yii::$app->user->id]),
        'sort' => [
            'attributes' => ['id', 'title', 'status', 'priority', 'created_at'],
            'defaultOrder' => ['created_at' => SORT_DESC],
        ],
        'pagination' => [
            'pageSize' => 20,
            'pageSizeLimit' => [1, 100], // min/max per-page
        ],
    ]);
}
# Sort by priority descending
GET /api/tasks?sort=-priority
 
# Sort by status ascending, then created_at descending
GET /api/tasks?sort=status,-created_at

The - prefix means descending order.

Filtering with Query Parameters

Implement filtering in prepareDataProvider:

public function prepareDataProvider()
{
    $request = \Yii::$app->request;
    $query = Task::find()->where(['user_id' => \Yii::$app->user->id]);
 
    // Filter by status
    $status = $request->get('status');
    if ($status !== null) {
        $query->andWhere(['status' => $status]);
    }
 
    // Filter by priority
    $priority = $request->get('priority');
    if ($priority !== null) {
        $query->andWhere(['priority' => $priority]);
    }
 
    // Search in title
    $search = $request->get('q');
    if ($search) {
        $query->andWhere(['like', 'title', $search]);
    }
 
    // Date range filter
    $from = $request->get('from');
    $to = $request->get('to');
    if ($from) {
        $query->andWhere(['>=', 'due_date', $from]);
    }
    if ($to) {
        $query->andWhere(['<=', 'due_date', $to]);
    }
 
    return new \yii\data\ActiveDataProvider([
        'query' => $query,
        'sort' => [
            'attributes' => ['id', 'title', 'status', 'priority', 'created_at', 'due_date'],
            'defaultOrder' => ['created_at' => SORT_DESC],
        ],
    ]);
}
# Tasks that are "in progress" with high priority
GET /api/tasks?status=2&priority=3
 
# Search for tasks containing "deploy"
GET /api/tasks?q=deploy
 
# Tasks due this week
GET /api/tasks?from=2026-03-18&to=2026-03-24
 
# Combined: search + filter + sort + paginate
GET /api/tasks?q=deploy&status=1&sort=-priority&page=1&per-page=5

Using DataFilter for Complex Filtering

For more advanced filtering, Yii2 provides DataFilter:

use yii\data\ActiveDataFilter;
 
public function prepareDataProvider()
{
    $filter = new ActiveDataFilter([
        'searchModel' => 'app\models\TaskSearch',
    ]);
 
    $filterCondition = null;
    if ($filter->load(\Yii::$app->request->get())) {
        $filterCondition = $filter->build();
        if ($filterCondition === false) {
            return $filter;
        }
    }
 
    $query = Task::find()->where(['user_id' => \Yii::$app->user->id]);
    if ($filterCondition !== null) {
        $query->andWhere($filterCondition);
    }
 
    return new \yii\data\ActiveDataProvider([
        'query' => $query,
    ]);
}
# Filter with operators
GET /api/tasks?filter[status]=1
GET /api/tasks?filter[priority][gte]=2
GET /api/tasks?filter[due_date][lt]=2026-04-01

5. Authentication — Bearer Tokens

REST APIs are stateless — no sessions, no cookies. The standard approach is Bearer token authentication: the client sends a token in the Authorization header with every request.

Configure the User Model

Your User model needs findIdentityByAccessToken() from IdentityInterface:

// models/User.php
class User extends ActiveRecord implements \yii\web\IdentityInterface
{
    // ... existing IdentityInterface methods ...
 
    public static function findIdentityByAccessToken($token, $type = null)
    {
        return static::findOne(['access_token' => $token]);
    }
 
    /**
     * Generate a new access token.
     */
    public function generateAccessToken()
    {
        $this->access_token = \Yii::$app->security->generateRandomString(32);
    }
}

Add Auth Behavior to Controller

// controllers/api/TaskController.php
namespace app\controllers\api;
 
use yii\rest\ActiveController;
use yii\filters\auth\HttpBearerAuth;
 
class TaskController extends ActiveController
{
    public $modelClass = 'app\models\Task';
 
    public function behaviors()
    {
        $behaviors = parent::behaviors();
 
        // Add Bearer token authentication
        $behaviors['authenticator'] = [
            'class' => HttpBearerAuth::class,
        ];
 
        return $behaviors;
    }
}

Making Requests with Authentication

# Login to get a token
curl -s -X POST http://localhost:8080/api/auth/login \
  -H "Content-Type: application/json" \
  -d '{"email": "chanh@example.com", "password": "secret123"}' | jq
 
# Response:
# {
#   "token": "abc123def456...",
#   "user": { "id": 1, "username": "chanh", "email": "chanh@example.com" }
# }
 
# Use the token for authenticated requests
curl -s http://localhost:8080/api/tasks \
  -H "Authorization: Bearer abc123def456..." | jq
 
# Without token → 401 Unauthorized
curl -s http://localhost:8080/api/tasks | jq
# {
#   "name": "Unauthorized",
#   "message": "Your request was made with invalid credentials.",
#   "code": 0,
#   "status": 401
# }

Multiple Auth Methods

Support both Bearer tokens (API clients) and session auth (same-domain JS):

use yii\filters\auth\HttpBearerAuth;
use yii\filters\auth\QueryParamAuth;
use yii\filters\auth\CompositeAuth;
 
public function behaviors()
{
    $behaviors = parent::behaviors();
 
    $behaviors['authenticator'] = [
        'class' => CompositeAuth::class,
        'authMethods' => [
            HttpBearerAuth::class,               // Authorization: Bearer <token>
            QueryParamAuth::class,               // ?access-token=<token>
        ],
    ];
 
    return $behaviors;
}

Optional Auth — Public + Private Endpoints

Some actions should work both authenticated and unauthenticated (e.g., public task list vs private tasks):

public function behaviors()
{
    $behaviors = parent::behaviors();
 
    $behaviors['authenticator'] = [
        'class' => HttpBearerAuth::class,
        'optional' => ['index', 'view'],  // auth optional for these
    ];
 
    return $behaviors;
}
 
public function prepareDataProvider()
{
    $query = Task::find()->where(['is_public' => true]);
 
    // If authenticated, also include their private tasks
    if (!\Yii::$app->user->isGuest) {
        $query->orWhere(['user_id' => \Yii::$app->user->id]);
    }
 
    return new \yii\data\ActiveDataProvider(['query' => $query]);
}

Auth Flow Sequence


6. Rate Limiting

Rate limiting prevents API abuse. Yii2 has built-in support — your User model just needs to implement RateLimiterInterface.

Implement RateLimiterInterface

// models/User.php
use yii\filters\RateLimitInterface;
 
class User extends ActiveRecord implements
    \yii\web\IdentityInterface,
    RateLimitInterface
{
    /**
     * Maximum number of requests allowed.
     */
    public function getRateLimit($request, $action)
    {
        // 100 requests per 60 seconds
        return [100, 60];
    }
 
    /**
     * Returns the current remaining allowance + last check time.
     */
    public function loadAllowance($request, $action)
    {
        return [
            $this->rate_limit_allowance ?? 100,
            $this->rate_limit_allowance_updated_at ?? time(),
        ];
    }
 
    /**
     * Saves the current allowance + timestamp.
     */
    public function saveAllowance($request, $action, $allowance, $timestamp)
    {
        $this->rate_limit_allowance = $allowance;
        $this->rate_limit_allowance_updated_at = $timestamp;
        $this->save(false);
    }
}

You'll need two columns on the users table:

// migrations/m260318_000001_add_rate_limit_to_users.php
$this->addColumn('{{%users}}', 'rate_limit_allowance', $this->integer());
$this->addColumn('{{%users}}', 'rate_limit_allowance_updated_at', $this->integer());

Enable Rate Limiting in Controller

public function behaviors()
{
    $behaviors = parent::behaviors();
 
    $behaviors['authenticator'] = [
        'class' => HttpBearerAuth::class,
    ];
 
    $behaviors['rateLimiter'] = [
        'class' => \yii\filters\RateLimiter::class,
        'enableRateLimitHeaders' => true,
    ];
 
    return $behaviors;
}

Rate Limit Headers

Every response includes rate limit information:

X-Rate-Limit-Limit: 100
X-Rate-Limit-Remaining: 97
X-Rate-Limit-Reset: 42

When the limit is exceeded:

{
    "name": "Too Many Requests",
    "message": "Rate limit exceeded.",
    "code": 0,
    "status": 429
}

Different Limits per Action

For more granular control, vary limits by action:

public function getRateLimit($request, $action)
{
    // Write operations get a lower limit
    if (in_array($action->id, ['create', 'update', 'delete'])) {
        return [20, 60]; // 20 writes per minute
    }
    return [100, 60]; // 100 reads per minute
}

7. API Versioning

As your API evolves, breaking changes are inevitable. Versioning lets you evolve the API without breaking existing clients. The most common and clearest strategy is URL prefix versioning.

Directory Structure

controllers/
├── api/
│   └── v1/
│       ├── TaskController.php
│       └── UserController.php
│   └── v2/
│       ├── TaskController.php
│       └── UserController.php

Version 1 Controller

// controllers/api/v1/TaskController.php
namespace app\controllers\api\v1;
 
use yii\rest\ActiveController;
use yii\filters\auth\HttpBearerAuth;
 
class TaskController extends ActiveController
{
    public $modelClass = 'app\models\Task';
 
    public function behaviors()
    {
        $behaviors = parent::behaviors();
        $behaviors['authenticator'] = [
            'class' => HttpBearerAuth::class,
        ];
        return $behaviors;
    }
}

Version 2 Controller

V2 might use different serialization, stricter validation, or a new response structure:

// controllers/api/v2/TaskController.php
namespace app\controllers\api\v2;
 
use yii\rest\ActiveController;
use yii\filters\auth\HttpBearerAuth;
 
class TaskController extends ActiveController
{
    public $modelClass = 'app\models\Task';
 
    // V2 wraps collections in an envelope
    public $serializer = [
        'class' => 'yii\rest\Serializer',
        'collectionEnvelope' => 'data',
        'metaEnvelope' => 'meta',
    ];
 
    public function behaviors()
    {
        $behaviors = parent::behaviors();
        $behaviors['authenticator'] = [
            'class' => HttpBearerAuth::class,
        ];
        return $behaviors;
    }
 
    public function actions()
    {
        $actions = parent::actions();
        // V2 doesn't support DELETE — use soft-delete via PATCH
        unset($actions['delete']);
        return $actions;
    }
}

URL Rules for Versioned API

// config/web.php
'rules' => [
    // V1 routes
    ['class' => 'yii\rest\UrlRule', 'controller' => 'api/v1/task',
        'prefix' => 'api/v1'],
    ['class' => 'yii\rest\UrlRule', 'controller' => 'api/v1/user',
        'prefix' => 'api/v1'],
 
    // V2 routes
    ['class' => 'yii\rest\UrlRule', 'controller' => 'api/v2/task',
        'prefix' => 'api/v2'],
 
    // Custom actions per version
    'POST api/v1/tasks/<id:\d+>/complete' => 'api/v1/task/complete',
    'POST api/v2/tasks/<id:\d+>/complete' => 'api/v2/task/complete',
],
# V1 — flat array
GET /api/v1/tasks
 
# V2 — envelope format
GET /api/v2/tasks
# { "data": [...], "meta": { "totalCount": 42, ... } }

Module-Based Versioning (Cleaner)

For larger APIs, use Yii2 modules:

// config/web.php
'modules' => [
    'v1' => [
        'class' => 'app\modules\v1\Module',
    ],
    'v2' => [
        'class' => 'app\modules\v2\Module',
    ],
],

This gives each version its own namespace, controllers, and configuration.


8. CORS Configuration

Cross-Origin Resource Sharing (CORS) is required when your API is on a different domain than the frontend. Without CORS headers, browsers block the request.

Enable CORS Filter

// controllers/api/TaskController.php
use yii\filters\Cors;
use yii\filters\auth\HttpBearerAuth;
 
public function behaviors()
{
    $behaviors = parent::behaviors();
 
    // CORS must come BEFORE authenticator
    $behaviors['cors'] = [
        'class' => Cors::class,
        'cors' => [
            'Origin' => [
                'https://myapp.com',
                'https://staging.myapp.com',
                'http://localhost:3000',  // dev
            ],
            'Access-Control-Request-Method' => [
                'GET', 'POST', 'PUT', 'PATCH', 'DELETE', 'OPTIONS',
            ],
            'Access-Control-Request-Headers' => ['*'],
            'Access-Control-Allow-Credentials' => true,
            'Access-Control-Max-Age' => 86400,  // cache preflight for 24h
            'Access-Control-Expose-Headers' => [
                'X-Pagination-Total-Count',
                'X-Pagination-Page-Count',
                'X-Pagination-Current-Page',
                'X-Pagination-Per-Page',
                'X-Rate-Limit-Limit',
                'X-Rate-Limit-Remaining',
                'X-Rate-Limit-Reset',
            ],
        ],
    ];
 
    // Auth AFTER cors
    $behaviors['authenticator'] = [
        'class' => HttpBearerAuth::class,
    ];
 
    return $behaviors;
}

Why Order Matters

If authenticator runs before CORS, the preflight OPTIONS request (which has no auth header) returns 401, and the browser never makes the actual request.

Expose Pagination Headers

By default, browsers can only read a few "simple" response headers. Custom headers like X-Pagination-* and X-Rate-Limit-* must be explicitly exposed via Access-Control-Expose-Headers, otherwise your JavaScript frontend can't read them.


9. Error Handling

Yii2's REST framework automatically converts exceptions to JSON error responses.

Standard Error Responses

// 400 Bad Request
{
    "name": "Bad Request",
    "message": "ids and status are required.",
    "code": 0,
    "status": 400
}
 
// 401 Unauthorized
{
    "name": "Unauthorized",
    "message": "Your request was made with invalid credentials.",
    "code": 0,
    "status": 401
}
 
// 404 Not Found
{
    "name": "Not Found",
    "message": "Task #999 not found.",
    "code": 0,
    "status": 404
}
 
// 422 Validation Error (model validation fails)
[
    {
        "field": "title",
        "message": "Title cannot be blank."
    },
    {
        "field": "email",
        "message": "This email is already taken."
    }
]
 
// 429 Too Many Requests
{
    "name": "Too Many Requests",
    "message": "Rate limit exceeded.",
    "code": 0,
    "status": 429
}

Throwing Exceptions

Use Yii2's HTTP exception classes:

use yii\web\BadRequestHttpException;
use yii\web\NotFoundHttpException;
use yii\web\ForbiddenHttpException;
use yii\web\UnauthorizedHttpException;
use yii\web\ServerErrorHttpException;
 
// 400
throw new BadRequestHttpException('Invalid date format.');
 
// 403
throw new ForbiddenHttpException('You cannot edit this task.');
 
// 404
throw new NotFoundHttpException('Task not found.');
 
// 500
throw new ServerErrorHttpException('Failed to save task.');

Validation Errors

When a model fails validation, ActiveController automatically returns 422 with the error details. You don't need to do anything special — just return the model:

public function actionCreate()
{
    $model = new Task();
    $model->load(\Yii::$app->request->post(), '');
 
    if (!$model->save()) {
        // Returning model with errors triggers 422 response
        return $model;
    }
 
    \Yii::$app->response->statusCode = 201;
    return $model;
}

Custom Error Handler for APIs

For consistent error format across the entire API:

// config/web.php
'components' => [
    'response' => [
        'class' => 'yii\web\Response',
        'on beforeSend' => function ($event) {
            $response = $event->sender;
            if ($response->data !== null && \Yii::$app->request->isAjax) {
                // Already properly formatted by REST controllers
                return;
            }
        },
    ],
],

10. Putting It All Together — Complete API Setup

Here's the full configuration for a production-ready REST API:

Configuration

// config/web.php
return [
    'id' => 'task-manager',
    'components' => [
        'urlManager' => [
            'enablePrettyUrl' => true,
            'showScriptName' => false,
            'rules' => [
                // Auth routes (no authentication)
                'POST api/v1/auth/login' => 'api/v1/auth/login',
                'POST api/v1/auth/register' => 'api/v1/auth/register',
 
                // Custom task actions
                'POST api/v1/tasks/<id:\d+>/complete' => 'api/v1/task/complete',
                'GET api/v1/tasks/stats' => 'api/v1/task/stats',
                'POST api/v1/tasks/bulk-update' => 'api/v1/task/bulk-update',
 
                // Standard REST routes
                ['class' => 'yii\rest\UrlRule',
                    'controller' => 'api/v1/task',
                    'prefix' => 'api/v1'],
            ],
        ],
        'request' => [
            'parsers' => [
                'application/json' => 'yii\web\JsonParser',
            ],
        ],
        'user' => [
            'identityClass' => 'app\models\User',
            'enableSession' => false,       // Stateless
            'loginUrl' => null,             // No redirect — return 401
        ],
    ],
];

Base API Controller

// controllers/api/v1/BaseController.php
namespace app\controllers\api\v1;
 
use yii\rest\Controller;
use yii\filters\Cors;
use yii\filters\auth\HttpBearerAuth;
use yii\filters\RateLimiter;
 
class BaseController extends Controller
{
    public function behaviors()
    {
        $behaviors = parent::behaviors();
 
        // 1. CORS (first)
        $behaviors['cors'] = [
            'class' => Cors::class,
            'cors' => [
                'Origin' => ['https://myapp.com', 'http://localhost:3000'],
                'Access-Control-Request-Method' => [
                    'GET', 'POST', 'PUT', 'PATCH', 'DELETE', 'OPTIONS',
                ],
                'Access-Control-Request-Headers' => ['*'],
                'Access-Control-Allow-Credentials' => true,
                'Access-Control-Max-Age' => 86400,
                'Access-Control-Expose-Headers' => [
                    'X-Pagination-Total-Count',
                    'X-Pagination-Page-Count',
                    'X-Pagination-Current-Page',
                    'X-Pagination-Per-Page',
                    'X-Rate-Limit-Limit',
                    'X-Rate-Limit-Remaining',
                    'X-Rate-Limit-Reset',
                ],
            ],
        ];
 
        // 2. Authentication
        $behaviors['authenticator'] = [
            'class' => HttpBearerAuth::class,
        ];
 
        // 3. Rate limiting
        $behaviors['rateLimiter'] = [
            'class' => RateLimiter::class,
            'enableRateLimitHeaders' => true,
        ];
 
        return $behaviors;
    }
}

Task Controller (Final)

// controllers/api/v1/TaskController.php
namespace app\controllers\api\v1;
 
use app\models\Task;
use yii\rest\ActiveController;
use yii\filters\Cors;
use yii\filters\auth\HttpBearerAuth;
use yii\filters\RateLimiter;
use yii\data\ActiveDataProvider;
 
class TaskController extends ActiveController
{
    public $modelClass = 'app\models\Task';
 
    public function behaviors()
    {
        $behaviors = parent::behaviors();
 
        $behaviors['cors'] = [
            'class' => Cors::class,
            'cors' => [
                'Origin' => ['https://myapp.com', 'http://localhost:3000'],
                'Access-Control-Request-Method' => [
                    'GET', 'POST', 'PUT', 'PATCH', 'DELETE', 'OPTIONS',
                ],
                'Access-Control-Request-Headers' => ['*'],
                'Access-Control-Allow-Credentials' => true,
                'Access-Control-Max-Age' => 86400,
                'Access-Control-Expose-Headers' => [
                    'X-Pagination-Total-Count',
                    'X-Pagination-Page-Count',
                    'X-Pagination-Current-Page',
                    'X-Pagination-Per-Page',
                    'X-Rate-Limit-Limit',
                    'X-Rate-Limit-Remaining',
                    'X-Rate-Limit-Reset',
                ],
            ],
        ];
 
        $behaviors['authenticator'] = [
            'class' => HttpBearerAuth::class,
        ];
 
        $behaviors['rateLimiter'] = [
            'class' => RateLimiter::class,
            'enableRateLimitHeaders' => true,
        ];
 
        return $behaviors;
    }
 
    public function actions()
    {
        $actions = parent::actions();
        unset($actions['delete']);
        $actions['index']['prepareDataProvider'] = [$this, 'prepareDataProvider'];
        return $actions;
    }
 
    public function prepareDataProvider()
    {
        $request = \Yii::$app->request;
        $query = Task::find()
            ->where(['user_id' => \Yii::$app->user->id]);
 
        // Filtering
        if (($status = $request->get('status')) !== null) {
            $query->andWhere(['status' => $status]);
        }
        if (($priority = $request->get('priority')) !== null) {
            $query->andWhere(['priority' => $priority]);
        }
        if ($search = $request->get('q')) {
            $query->andWhere(['like', 'title', $search]);
        }
 
        return new ActiveDataProvider([
            'query' => $query,
            'sort' => [
                'attributes' => [
                    'id', 'title', 'status', 'priority',
                    'due_date', 'created_at',
                ],
                'defaultOrder' => ['created_at' => SORT_DESC],
            ],
            'pagination' => [
                'pageSize' => 20,
                'pageSizeLimit' => [1, 100],
            ],
        ]);
    }
 
    public function actionComplete($id)
    {
        $task = $this->findModel($id);
        $task->status = Task::STATUS_DONE;
        $task->completed_at = date('Y-m-d H:i:s');
 
        if (!$task->save()) {
            return $task;
        }
 
        return $task;
    }
 
    public function actionStats()
    {
        $userId = \Yii::$app->user->id;
        $base = Task::find()->where(['user_id' => $userId]);
 
        return [
            'total' => (clone $base)->count(),
            'todo' => (clone $base)->andWhere(['status' => Task::STATUS_TODO])->count(),
            'in_progress' => (clone $base)->andWhere(['status' => Task::STATUS_IN_PROGRESS])->count(),
            'done' => (clone $base)->andWhere(['status' => Task::STATUS_DONE])->count(),
        ];
    }
 
    protected function findModel($id)
    {
        $model = Task::findOne([
            'id' => $id,
            'user_id' => \Yii::$app->user->id,
        ]);
        if ($model === null) {
            throw new \yii\web\NotFoundHttpException("Task #$id not found.");
        }
        return $model;
    }
}

Complete API Request Flow


API Endpoint Reference

MethodURLAuthDescription
POST/api/v1/auth/loginNoLogin, get token
POST/api/v1/auth/registerNoRegister, get token
GET/api/v1/tasksYesList tasks (paginated, filterable, sortable)
GET/api/v1/tasks/:idYesGet single task
POST/api/v1/tasksYesCreate task
PUT/PATCH/api/v1/tasks/:idYesUpdate task
POST/api/v1/tasks/:id/completeYesMark task as done
GET/api/v1/tasks/statsYesTask count by status

Query Parameters

ParameterExampleDescription
page?page=2Page number
per-page?per-page=10Items per page (1-100)
sort?sort=-priority,statusSort fields (- = desc)
q?q=deploySearch in title
status?status=1Filter by status
priority?priority=3Filter by priority
fields?fields=id,title,statusSelect specific fields
expand?expand=user,commentsInclude relations

What's Next

Your task manager now has a complete REST API:

  • ActiveController provides CRUD with zero boilerplate
  • Custom actions handle business logic (complete, stats, bulk-update)
  • fields() and extraFields() control exactly what clients see
  • Pagination, sorting, and filtering work via query parameters
  • Bearer token authentication secures every endpoint
  • Rate limiting prevents abuse (100 req/min per user)
  • API versioning supports gradual evolution
  • CORS lets frontends on different domains call the API

Deep Dive → Widgets, Asset Bundles & Frontend Integration — build custom widgets, manage CSS/JS assets, use GridView, ListView, Pjax, and integrate npm packages.

← Forms, Validation & Data Input | → Series Overview

📬 Subscribe to Newsletter

Get the latest blog posts delivered to your inbox every week. No spam, unsubscribe anytime.

We respect your privacy. Unsubscribe at any time.

💬 Comments

Sign in to leave a comment

We'll never post without your permission.