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 Method | URL | Action | Description |
|---|---|---|---|
GET | /api/tasks | index | List all tasks |
GET | /api/tasks/42 | view | Get task #42 |
POST | /api/tasks | create | Create a task |
PUT | /api/tasks/42 | update | Update task #42 |
PATCH | /api/tasks/42 | update | Partial update task #42 |
DELETE | /api/tasks/42 | delete | Delete task #42 |
OPTIONS | /api/tasks | options | CORS preflight |
HEAD | /api/tasks | index | Headers only |
Note how it pluralizes task → tasks 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 ContentDefault 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=userResponse 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=10Pagination 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_atThe - 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=5Using 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-015. 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: 42When 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.phpVersion 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
| Method | URL | Auth | Description |
|---|---|---|---|
POST | /api/v1/auth/login | No | Login, get token |
POST | /api/v1/auth/register | No | Register, get token |
GET | /api/v1/tasks | Yes | List tasks (paginated, filterable, sortable) |
GET | /api/v1/tasks/:id | Yes | Get single task |
POST | /api/v1/tasks | Yes | Create task |
PUT/PATCH | /api/v1/tasks/:id | Yes | Update task |
POST | /api/v1/tasks/:id/complete | Yes | Mark task as done |
GET | /api/v1/tasks/stats | Yes | Task count by status |
Query Parameters
| Parameter | Example | Description |
|---|---|---|
page | ?page=2 | Page number |
per-page | ?per-page=10 | Items per page (1-100) |
sort | ?sort=-priority,status | Sort fields (- = desc) |
q | ?q=deploy | Search in title |
status | ?status=1 | Filter by status |
priority | ?priority=3 | Filter by priority |
fields | ?fields=id,title,status | Select specific fields |
expand | ?expand=user,comments | Include relations |
What's Next
Your task manager now has a complete REST API:
ActiveControllerprovides CRUD with zero boilerplate- Custom actions handle business logic (complete, stats, bulk-update)
fields()andextraFields()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.
📬 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.