Bootstrap a full REST API from a PostgreSQL database with advanced relationship management, filtering, and validation rule exposure.
# 1. Copy and configure environment
cp .env.example .env
# Edit .env to set database credentials, USER_EMAIL & USER_PASSWORD, etc.
# 2. Start containers
docker compose up -d
# 3. Import your schema (from SQL schema export)
./yii migrate/import-schema your-schema.sql --force --run
# 4. Generate API with Faker data
./yii setup/generate-all --seed=100That's it. Your API is now running. Controllers are auto-discovered and routed and your complete HTTP spec is available in requests/.
- Commands
- Database Seeding
- Modules
- Project Structure
- Database Schema Example
- Behaviors
- Traits
- Actions
- Base Classes
- Models
- Error Handling
- Integration Patterns
- API Examples
Parses a postgres schema dump and splits it into migration files:
00000_create_schemas.sql- Schema definitions00001_create_*.sql- Tables and sequences09999_add_foreign_keys.sql- Constraints
Options:
--force- Overwrite existing migration files--run- Execute the generated migrations immediately against the database
Generates ActiveRecord models from database tables.
Options:
--overwrite=1- Overwrite existing files--excludeTables=x,y- Skip specific tables--specificTables=x,y- Only generate for specific tables
Generates REST controllers for each model.
Options:
--overwrite=1- Overwrite existing files--excludeTables=x,y- Skip specific tables--specificTables=x,y- Only generate for specific tables
Generates .http files in requests/ with examples for all endpoints:
- CRUD operations
- Filtering (
?filter[field][like]=value) - Sorting (
?sort=-field) - Pagination (
?per-page=10) - Relation expansion (
?expand=relation)
Options:
--outputPath=/path- Custom output directory--excludeTables=x,y- Skip specific tables--specificTables=x,y- Only generate for specific tables
Creates a new module for a database schema.
# Create 'my_module' module for 'my_module' schema
./yii module/create my_module
# Create 'orders' module mapped to 'my_module' schema
./yii module/create my_module --module-name=ordersLists all existing modules with their schema mappings, controller counts, and model counts.
Detects all schemas in the database and creates modules for any that don't have one. Useful when working with multi-schema databases.
Analyzes all tables and reports how each column will be seeded. Shows confidence levels and suggests @faker hints for low-confidence columns.
Options:
--schema=x,y- Analyze specific schemas only--excludeTables=x,y- Skip specific tables
Seeds tables with realistic faker data. Tables are seeded in foreign key dependency order (parents before children).
Options:
--count=100- Records per table (default: 10)--schema=x,y- Seed specific schemas only--table=schema.table- Seed specific table only--truncate- Truncate tables before seeding--locale=de_DE- Faker locale (default: en_US)--excludeTables=x,y- Skip specific tables--interactive=0- Non-interactive mode
Note: Use --truncate when re-seeding to avoid duplicate key errors.
Clears all data from tables (in reverse dependency order to respect foreign keys).
Options:
--schema=x,y- Truncate specific schemas only--table=schema.table- Truncate specific table only--interactive=0- Non-interactive mode
The unified pipeline now supports seeding. Add --seed=N to seed N records per table after generation:
# Generate API + seed 100 records per table
./yii setup/generate-all --seed=100
# Generate for specific schema with seeding
./yii setup/generate-all --schema=sales --seed=50
# With German locale
./yii setup/generate-all --seed=100 --seedLocale=de_DERESTstop includes a powerful seeder that generates realistic fake data for any database schema with minimal configuration.
The seeder uses a priority chain to determine the appropriate faker method for each column:
- Column comment
@fakerhint (highest priority) - explicit control via database comments - Foreign key detection - automatically picks random IDs from referenced tables
- Column name pattern matching - recognizes common names like
email,first_name,phone - Type-based fallback (lowest priority) - generic data based on column type
# See how columns will be seeded
./yii seeder/analyze
# Seed all tables with 100 records each
./yii seeder/seed --count=100
# Or use the unified pipeline
./yii setup/generate-all --seed=100For columns that can't be auto-detected, add hints directly in your database schema using PostgreSQL comments. These hints travel with your migrations and pg_dump:
-- Basic faker method
COMMENT ON COLUMN public.user.email IS '@faker=email';
COMMENT ON COLUMN public.company.name IS '@faker=company';
COMMENT ON COLUMN public.address.city IS '@faker=city';
-- Methods with arguments
COMMENT ON COLUMN products.price IS '@faker=randomFloat(2,10,1000)';
COMMENT ON COLUMN users.age IS '@faker=numberBetween(18,80)';
-- Array/enum values
COMMENT ON COLUMN users.status IS '@faker=randomElement([0,1,2])';
COMMENT ON COLUMN orders.priority IS '@faker=randomElement([low,medium,high])';
-- Skip seeding (computed columns, etc)
COMMENT ON COLUMN users.full_name IS '@faker=skip';
-- Existing comments are preserved
COMMENT ON COLUMN users.email IS 'Primary contact email @faker=email';-- Names
@faker=firstName
@faker=lastName
@faker=name -- Full name
@faker=userName
@faker=title -- Mr., Mrs., etc.
-- Text content
@faker=sentence -- Single sentence
@faker=sentence(10) -- 10 words
@faker=paragraph
@faker=paragraph(3) -- 3 sentences
@faker=text -- ~200 chars
@faker=text(500) -- 500 chars
@faker=word
@faker=words(5) -- Array of 5 words
@faker=slug
-- Formatted strings
@faker=lexify(?????) -- Random letters: "abcde"
@faker=numerify(###-###) -- Random numbers: "123-456"
@faker=bothify(??-###) -- Mixed: "ab-123"
@faker=regexify([A-Z]{3}[0-9]{4}) -- From regex: "ABC1234"@faker=randomNumber -- Random integer
@faker=randomNumber(5) -- Max 5 digits
@faker=numberBetween(1,100) -- Range
@faker=randomFloat(2,0,1000) -- 2 decimals, 0-1000
@faker=randomDigit -- 0-9
@faker=randomDigitNot(0) -- 1-9
@faker=boolean -- true/false
@faker=boolean(70) -- 70% chance true@faker=date -- "2024-03-15"
@faker=date(Y-m-d,now) -- Format, max date
@faker=time -- "14:32:05"
@faker=dateTime -- DateTime object → "2024-03-15 14:32:05"
@faker=dateTimeBetween(-1 year,now) -- Range
@faker=dateTimeThisYear
@faker=dateTimeThisMonth
@faker=year
@faker=month
@faker=dayOfWeek
@faker=unixTime
@faker=iso8601
@faker=timezone@faker=email
@faker=safeEmail -- @example.com domain
@faker=freeEmail -- @gmail.com, etc.
@faker=companyEmail -- @company domain
@faker=url
@faker=domainName
@faker=ipv4
@faker=ipv6
@faker=macAddress
@faker=userAgent
@faker=slug
@faker=password -- Hashed password
@faker=sha256 -- SHA-256 hash
@faker=uuid@faker=address -- Full address
@faker=streetAddress
@faker=streetName
@faker=buildingNumber
@faker=city
@faker=state
@faker=stateAbbr
@faker=postcode
@faker=country
@faker=countryCode
@faker=latitude
@faker=longitude@faker=company
@faker=companySuffix -- Inc, LLC, etc.
@faker=jobTitle
@faker=bs -- Business buzzwords
@faker=catchPhrase
-- Commerce
@faker=productName
@faker=department
@faker=ean13 -- Barcode
@faker=isbn13
@faker=currencyCode -- USD, EUR, etc.
@faker=creditCardNumber
@faker=creditCardType
@faker=iban
@faker=swiftBicNumber@faker=phoneNumber
@faker=e164PhoneNumber -- +12025551234
@faker=tollFreePhoneNumber@faker=mimeType
@faker=fileExtension
@faker=imageUrl -- Placeholder image URL
@faker=imageUrl(640,480) -- With dimensions
@faker=imageUrl(640,480,cats) -- With category@faker=hexColor -- #fa3b2c
@faker=rgbColor -- 255,128,0
@faker=colorName -- blue, red
@faker=locale -- en_US
@faker=languageCode -- en, de, fr
@faker=countryISOAlpha3 -- USA, DEU
@faker=emoji
@faker=md5
@faker=sha1
@faker=sha256
-- Randomness
@faker=randomElement([a,b,c]) -- Pick from array
@faker=randomElements([a,b,c],2) -- Pick 2 from array
@faker=shuffle([a,b,c]) -- Shuffle array
@faker=unique(email) -- Guarantee uniquenessThe seeder automatically recognizes these column name patterns without needing @faker hints:
Exact matches:
| Column Name | Faker Method |
|---|---|
email |
email |
first_name, firstname |
firstName |
last_name, lastname |
lastName |
phone, phone_number |
phoneNumber |
city |
city |
state |
state |
country |
country |
zip, postal_code, postcode |
postcode |
address, street_address |
streetAddress |
company, company_name |
company |
username, user_name |
userName |
url, website |
url |
description, bio, notes |
text |
title |
jobTitle |
ip, ip_address |
ipv4 |
uuid, guid |
uuid |
latitude, lat |
latitude |
longitude, lng |
longitude |
password |
hashed "password" |
auth_key, access_token |
random token |
Suffix patterns:
| Pattern | Faker Method |
|---|---|
*_at (created_at, updated_at) |
dateTime |
*_date |
date |
*_time |
time |
*_email |
email |
*_url |
url |
*_token, *_hash |
SHA-256 hash |
*_ip |
ipv4 |
*_count, *_number |
numberBetween |
Prefix patterns:
| Pattern | Faker Method |
|---|---|
is_*, has_*, can_* |
boolean |
min_* |
numberBetween(0,50) |
max_* |
numberBetween(50,100) |
total_*, count_* |
numberBetween(0,1000) |
Foreign key columns are automatically detected and populated with random IDs from the referenced table:
# Automatic FK resolution
user.company_id → picks random id from company table
post.author_id → picks random id from user table
Tables are seeded in dependency order (parents first), so referenced records always exist.
For self-referencing foreign keys (e.g., user.manager_id → user.id):
- First half of records get
NULLfor the self-reference - Second half get random IDs from previously created records
Always run analyze first to check detection accuracy:
./yii seeder/analyze --schema=publicOutput shows each column with its detected method, source, and confidence:
Schema: public
──────────────────────────────────────────────────────────────────────
Table: user
┌────────────────────┬────────────────────┬───────────────┬──────────┐
│Column │Method │Source │Confidence│
├────────────────────┼────────────────────┼───────────────┼──────────┤
│id │(auto-increment) │skip │N/A │
│email │email │exact match │HIGH │
│first_name │firstName │exact match │HIGH │
│password_hash │sha256 │suffix pattern │MEDIUM │
│company_id │[FK] company.id │foreign key │HIGH │
│status │numberBetween │type fallback │LOW │
└────────────────────┴────────────────────┴───────────────┴──────────┘
Suggested hints:
COMMENT ON COLUMN user.status IS '@faker=randomElement([0,1,2])';
Add @faker hints for any LOW confidence columns, then seed:
./yii seeder/seed --count=100Generate localized fake data by specifying a locale:
# German names, addresses, phone numbers
./yii seeder/seed --count=100 --locale=de_DE
# French
./yii seeder/seed --count=100 --locale=fr_FR
# Japanese
./yii seeder/seed --count=100 --locale=ja_JPAvailable locales: en_US, en_GB, de_DE, fr_FR, es_ES, it_IT, pt_BR, ja_JP, zh_CN, ko_KR, ru_RU, and many more.
RESTstop supports multi-schema PostgreSQL databases through a module system. Each database schema maps to an API module containing its own models and controllers.
Modules and controllers are auto-discovered at runtime:
- Modules are detected from
src/api/modules/*/Module.php - Controllers are detected from each module's
controllers/directory - URL routes are automatically generated as
/{module}/{controller}
By default, modules map 1:1 with database schemas (e.g., my_module schema → my_module module). For custom mappings, define $dbSchema in the Module class:
// src/api/modules/orders/Module.php
namespace api\modules\orders;
class Module extends \yii\base\Module
{
public $controllerNamespace = 'api\modules\orders\controllers';
// Maps this module to the 'my_module' database schema
public $dbSchema = 'my_module';
}The generators (model/generate-models, controller/generate-controllers, http/generate-all) automatically route tables to the correct module based on their schema.
Each module follows a standard structure:
src/api/modules/{module}/
├── Module.php # Module configuration
├── controllers/ # REST controllers
│ └── ProductController.php
└── models/ # ActiveRecord models
└── Product.php
# 1. Sync modules with database schemas
./yii module/sync
# 2. Generate modules, models, controllers and HTTP request spec for all database tables
./yii setup/generate-allThat's it. You now have a fully function REST API mirroring your entire database.
Tables from schema my_module will generate models/controllers in src/api/modules/my_module/, accessible at /my_module/{controller}.
src/
├── api/
│ ├── config/
│ │ └── main.php # Auto-discovers modules and controllers
│ └── modules/
│ ├── public/ # Default module (public schema)
│ │ ├── Module.php
│ │ ├── models/
│ │ └── controllers/
│ └── {schema}/ # Additional modules per schema
│ ├── Module.php
│ ├── models/
│ └── controllers/
├── console/
│ ├── controllers/
│ │ ├── ModelController.php # ./yii model/*
│ │ ├── ControllerController.php # ./yii controller/*
│ │ ├── HttpController.php # ./yii http/*
│ │ ├── ModuleController.php # ./yii module/*
│ │ └── SeederController.php # ./yii seeder/*
│ └── components/
│ └── seeder/ # Seeder infrastructure
│ ├── FakerResolver.php
│ ├── PatternDictionary.php
│ ├── TableDependencyResolver.php
│ └── ColumnAnalyzer.php
├── common/
│ ├── actions/ # Custom REST actions
│ │ └── RulesAction.php
│ ├── behaviors/ # Model behaviors
│ │ ├── LinkBehavior.php
│ │ └── RelationsBehavior.php
│ ├── controllers/ # Base controllers
│ │ └── BaseController.php
│ ├── errorHandler/ # Custom error handling
│ │ └── CustomErrorHandler.php
│ ├── exceptions/ # Custom exceptions
│ │ ├── BaseHttpException.php
│ │ └── LinkException.php
│ ├── models/ # Base models
│ │ ├── BaseActiveRecord.php
│ │ ├── BaseActiveQuery.php
│ │ └── SearchableModel.php
│ └── traits/ # Reusable traits
│ ├── SearchableTrait.php
│ └── VirtualAttributesTrait.php
docker/postgres/migrations/ # Generated SQL migrations
requests/ # Generated .http files
The code examples throughout this documentation reference the following schema. It demonstrates common patterns: user authentication, role-based permissions via junction tables, and content with tagging.
erDiagram
User ||--o{ UserRole : has
Role ||--o{ UserRole : assigned_to
User ||--o{ Post : writes
Post ||--o{ PostTag : has
Tag ||--o{ PostTag : applied_to
User {
int id PK
string email UK
string password_hash
string access_token
timestamp created_at
timestamp updated_at
}
Role {
int id PK
string name UK
string description
}
UserRole {
int user_id FK
int role_id FK
boolean is_primary
timestamp assigned_at
}
Post {
int id PK
int user_id FK
string title
text content
string status
timestamp published_at
}
Tag {
int id PK
string name UK
string color
}
PostTag {
int post_id FK
int tag_id FK
int weight
}
SQL Schema:
CREATE TABLE "user" (
id SERIAL PRIMARY KEY,
email VARCHAR(255) NOT NULL UNIQUE,
password_hash VARCHAR(60) NOT NULL,
access_token VARCHAR(255),
created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
updated_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP
);
CREATE TABLE role (
id SERIAL PRIMARY KEY,
name VARCHAR(50) NOT NULL UNIQUE,
description TEXT
);
CREATE TABLE user_role (
user_id INTEGER NOT NULL REFERENCES "user"(id) ON DELETE CASCADE,
role_id INTEGER NOT NULL REFERENCES role(id) ON DELETE CASCADE,
is_primary BOOLEAN DEFAULT false,
assigned_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
PRIMARY KEY (user_id, role_id)
);
CREATE TABLE post (
id SERIAL PRIMARY KEY,
user_id INTEGER NOT NULL REFERENCES "user"(id),
title VARCHAR(255) NOT NULL,
content TEXT,
status VARCHAR(20) DEFAULT 'draft',
published_at TIMESTAMP
);
CREATE TABLE tag (
id SERIAL PRIMARY KEY,
name VARCHAR(50) NOT NULL UNIQUE,
color VARCHAR(7)
);
CREATE TABLE post_tag (
post_id INTEGER NOT NULL REFERENCES post(id) ON DELETE CASCADE,
tag_id INTEGER NOT NULL REFERENCES tag(id) ON DELETE CASCADE,
weight INTEGER DEFAULT 0,
PRIMARY KEY (post_id, tag_id)
);Handles many-to-many relationship linking/unlinking on UPDATE events with transaction support.
Location: src/common/behaviors/LinkBehavior.php
Features:
- Atomic transactions with configurable rollback
- Array syntax:
[12, 15, -3](link 12 & 15, unlink 3) - Object syntax with junction data:
{"12": {"weight": 5}, "-3": null} - Error collection for partial failure handling
Model Setup:
namespace api\modules\public\models;
use common\models\BaseActiveRecord;
use common\behaviors\LinkBehavior;
class Post extends BaseActiveRecord
{
public function behaviors()
{
return [
'link' => [
'class' => LinkBehavior::class,
'rollbackOnError' => true, // All-or-nothing (default)
],
];
}
public function extraFields()
{
return ['tags']; // Relations that can be linked via API
}
public function getTags()
{
return $this->hasMany(Tag::class, ['id' => 'tag_id'])
->viaTable('post_tag', ['post_id' => 'id']);
}
// Junction relation for LinkBehavior (convention: get{OwnerClass}{RelationPlural})
public function getPostTags()
{
return $this->hasMany(PostTag::class, ['post_id' => 'id']);
}
}API Usage:
### Link tags to a post (array syntax)
PUT /api/posts/1
Content-Type: application/json
{
"title": "Updated Post",
"tags": [5, 8, 12]
}
### Link tags with junction data (object syntax)
PUT /api/posts/1
Content-Type: application/json
{
"tags": {
"5": {"weight": 10},
"8": {"weight": 5},
"-3": null
}
}
### Mixed: link some, unlink others
PUT /api/posts/1
Content-Type: application/json
{
"tags": [5, 8, -3, -7]
}Response on Error:
{
"name": "Unprocessable Entity",
"message": {
"tags": {
"999": "Record with ID 999 not found for relation tags"
}
},
"code": 0,
"status": 422
}Handles many-to-many linking on both INSERT and UPDATE events. Complements LinkBehavior by supporting POST (create) operations.
Location: src/common/behaviors/RelationsBehavior.php
Features:
- Works on both INSERT and UPDATE events
- Same array/object syntax as LinkBehavior
- Optional deferral to LinkBehavior for UPDATE events
- Configurable relation filtering
Model Setup:
namespace api\modules\public\models;
use common\models\BaseActiveRecord;
use common\behaviors\RelationsBehavior;
use common\behaviors\LinkBehavior;
class User extends BaseActiveRecord
{
public function behaviors()
{
return [
'relations' => [
'class' => RelationsBehavior::class,
'relations' => ['roles'], // Optional: specific relations only
'deferUpdatesToLinkBehavior' => true, // Let LinkBehavior handle UPDATE
],
'link' => [
'class' => LinkBehavior::class,
],
];
}
public function extraFields()
{
return ['roles'];
}
public function getRoles()
{
return $this->hasMany(Role::class, ['id' => 'role_id'])
->viaTable('user_role', ['user_id' => 'id']);
}
public function getUserRoles()
{
return $this->hasMany(UserRole::class, ['user_id' => 'id']);
}
}API Usage:
### Create user with roles (RelationsBehavior handles INSERT)
POST /api/users
Content-Type: application/json
{
"email": "admin@example.com",
"password": "secret123",
"roles": [1, 2]
}
### Create with junction data
POST /api/users
Content-Type: application/json
{
"email": "admin@example.com",
"password": "secret123",
"roles": {
"1": {"is_primary": true},
"2": {"is_primary": false}
}
}Provides standardized search/filter capability for REST controllers using Yii2's ActiveDataFilter.
Location: src/common/traits/SearchableTrait.php
Features:
- Query-string based filtering with operators
- Attribute whitelisting for security
- PostgreSQL ILIKE support
- Configurable pagination and sorting
Supported Operators:
| Operator | SQL | Example |
|---|---|---|
eq |
= value |
?filter[status]=active |
neq |
<> value |
?filter[status][neq]=deleted |
lt |
< value |
?filter[price][lt]=100 |
lte |
<= value |
?filter[price][lte]=100 |
gt |
> value |
?filter[price][gt]=50 |
gte |
>= value |
?filter[price][gte]=50 |
like |
LIKE %value% |
?filter[name][like]=john |
in |
IN (...) |
?filter[id][in][]=1&filter[id][in][]=2 |
Controller Setup:
namespace api\modules\public\controllers;
use common\controllers\BaseController;
use common\traits\SearchableTrait;
class PostController extends BaseController
{
use SearchableTrait;
public $modelClass = 'api\modules\public\models\Post';
// Whitelist filterable attributes (security)
public $filterableAttributes = ['title', 'status', 'user_id', 'published_at'];
public function actions()
{
$actions = parent::actions();
$actions['index']['prepareDataProvider'] = [$this, 'prepareSearchDataProvider'];
return $actions;
}
// Optional: customize pagination
protected function getPaginationConfig(): array
{
return [
'params' => \Yii::$app->request->getQueryParams(),
'defaultPageSize' => 25,
'pageSizeLimit' => [1, 100],
];
}
// Optional: customize sorting
protected function getSortConfig(): array
{
return [
'params' => \Yii::$app->request->getQueryParams(),
'defaultOrder' => ['published_at' => SORT_DESC],
];
}
}API Usage:
### Filter by exact value
GET /api/posts?filter[status]=published
### Filter with operator
GET /api/posts?filter[title][like]=tutorial
### Multiple filters
GET /api/posts?filter[status]=published&filter[user_id]=5
### Numeric comparisons
GET /api/posts?filter[published_at][gte]=2024-01-01
### Sorting (- prefix for descending)
GET /api/posts?sort=-published_at,title
### Pagination
GET /api/posts?page=2&per-page=25
### Combined
GET /api/posts?filter[status]=published&sort=-published_at&page=1&per-page=10Provides dynamic computed attributes not backed by database columns.
Location: src/common/traits/VirtualAttributesTrait.php
Features:
- Seamless ActiveRecord integration
- Magic method support (
__get,__set,__isset,__unset) - Bulk operations for virtual attributes
- Does not interfere with model saving
Model Setup:
namespace api\modules\public\models;
use common\models\BaseActiveRecord;
use common\traits\VirtualAttributesTrait;
class User extends BaseActiveRecord
{
use VirtualAttributesTrait;
public function afterFind()
{
parent::afterFind();
// Computed attribute
$this->fullName = trim($this->first_name . ' ' . $this->last_name);
// Aggregated data
$this->postCount = $this->getPosts()->count();
}
public function fields()
{
$fields = parent::fields();
// Include virtual attributes in API response
$fields['fullName'] = function ($model) {
return $model->fullName ?? null;
};
$fields['postCount'] = function ($model) {
return $model->postCount ?? 0;
};
return $fields;
}
}Usage:
$user = User::findOne(1);
// Set virtual attributes
$user->fullName = 'John Doe';
$user->permissions = ['read', 'write'];
// Get virtual attributes
echo $user->fullName; // 'John Doe'
// Check existence
if ($user->hasVirtualAttribute('fullName')) {
// ...
}
// Bulk operations
$user->setVirtualAttributes([
'fullName' => 'John Doe',
'isAdmin' => true,
]);
$virtuals = $user->getVirtualAttributes();
// ['fullName' => 'John Doe', 'isAdmin' => true]
// Real attributes still work normally
$user->email = 'john@example.com';
$user->save(); // Only saves email, not virtual attributesAPI Response:
{
"id": 1,
"email": "john@example.com",
"first_name": "John",
"last_name": "Doe",
"fullName": "John Doe",
"postCount": 42
}Exposes model validation rules via API endpoint, enabling frontend clients to build dynamic validated forms.
Location: src/common/actions/RulesAction.php
Features:
- Parses all Yii2 validator types
- Extracts constraints (min, max, pattern, etc.)
- Groups rules by field name
- Filters internal validator config
Controller Setup:
RulesAction is automatically registered by BaseController, but can be customized:
namespace api\modules\public\controllers;
use common\controllers\BaseController;
class UserController extends BaseController
{
public $modelClass = 'api\modules\public\models\User';
public function actions()
{
$actions = parent::actions();
// Customize rules action
$actions['rules']['checkAccess'] = [$this, 'checkAccess'];
return $actions;
}
}Model Validation Rules:
class User extends BaseActiveRecord
{
public function rules()
{
return [
[['email', 'password_hash'], 'required'],
[['email'], 'email'],
[['email'], 'string', 'max' => 255],
[['password_hash'], 'string', 'min' => 8, 'max' => 60],
[['status'], 'integer', 'min' => 0, 'max' => 10],
[['email'], 'unique'],
];
}
}API Request:
GET /api/users/rulesAPI Response:
{
"email": {
"rules": ["required", "email", "string", "unique"],
"constraints": {
"string": {"max": 255}
}
},
"password_hash": {
"rules": ["required", "string"],
"constraints": {
"string": {"min": 8, "max": 60}
}
},
"status": {
"rules": ["integer"],
"constraints": {
"integer": {"min": 0, "max": 10}
}
}
}Frontend Usage Example (JavaScript):
async function loadFormRules(resource) {
const response = await fetch(`/api/${resource}/rules`);
const rules = await response.json();
// Apply rules to form fields
Object.entries(rules).forEach(([field, config]) => {
const input = document.querySelector(`[name="${field}"]`);
if (!input) return;
if (config.rules.includes('required')) {
input.required = true;
}
if (config.constraints?.string?.max) {
input.maxLength = config.constraints.string.max;
}
if (config.constraints?.string?.min) {
input.minLength = config.constraints.string.min;
}
if (config.rules.includes('email')) {
input.type = 'email';
}
});
}Base model providing junction field integration for many-to-many relationships.
Location: src/common/models/BaseActiveRecord.php
Features:
- Merges junction table fields into model output
- Request-aware field expansion
- Integrates with BaseActiveQuery
Model Setup:
namespace api\modules\public\models;
use common\models\BaseActiveRecord;
use common\models\BaseActiveQuery;
class Tag extends BaseActiveRecord
{
// Define junction fields to include when this model is expanded
public static function junctionFields()
{
return [
'posts' => ['weight'], // Include 'weight' from post_tag junction
];
}
public function getTags()
{
return $this->hasMany(Tag::class, ['id' => 'tag_id'])
->viaTable('post_tag', ['post_id' => 'id']);
}
// Use BaseActiveQuery for junction data support
public static function find()
{
return new BaseActiveQuery(get_called_class());
}
}API Usage:
### Expand tags with junction data
GET /api/posts/1?expand=tagsResponse:
{
"id": 1,
"title": "My Post",
"tags": [
{
"id": 5,
"name": "PHP",
"weight": 10
},
{
"id": 8,
"name": "Tutorial",
"weight": 5
}
]
}Custom ActiveQuery for automatic junction data inclusion.
Location: src/common/models/BaseActiveQuery.php
Features:
- Automatic junction field aliasing
- Smart JOIN clause generation
- Transparent viaTable integration
Usage:
// In model relation definition
public function getTags()
{
return $this->hasMany(Tag::class, ['id' => 'tag_id'])
->viaTable('post_tag', ['post_id' => 'id']); // Automatically includes junction data
}
// Manual junction data inclusion
$tags = $post->getTags()->withJunctionData()->all();Base REST controller providing CORS support and automatic RulesAction registration.
Location: src/common/controllers/BaseController.php
Features:
- Automatic CORS configuration
- Built-in RulesAction
- Bearer token extraction helper
Configuration:
// config/params.php
return [
'cors' => [
'allowedOrigins' => ['http://localhost:3000', 'https://app.example.com'],
'allowedHeaders' => ['Content-Type', 'Authorization', 'X-Requested-With'],
'exposedHeaders' => ['X-Pagination-Total-Count', 'X-Pagination-Page-Count'],
'allowCredentials' => true,
'maxAge' => 86400,
],
];Controller Usage:
namespace api\modules\public\controllers;
use common\controllers\BaseController;
class ProductController extends BaseController
{
public $modelClass = 'api\modules\public\models\Product';
// RulesAction is automatically available at /api/products/rules
// Access Bearer token if needed
public function actionCustom()
{
$token = $this->getAccessTokenFromRequest();
// ...
}
}Dynamic model for search/filter operations without creating custom search classes.
Location: src/common/models/SearchableModel.php
Features:
- Dynamic attribute definition
- Multiple filter operators
- Automatic validation rules
- Schema type inference
Direct Usage:
use common\models\SearchableModel;
// Create search model with attributes
$searchModel = new SearchableModel();
$searchModel->defineAttributes(['name', 'status', 'price']);
$searchModel->defineTypesFromModel(Product::class);
// Set search values
$searchModel->name = 'shirt';
$searchModel->price = 50;
// Apply to query with operators
$query = Product::find();
$searchModel->search($query, [
'name' => 'like', // WHERE name LIKE '%shirt%'
'price' => 'lt', // AND price < 50
]);
$products = $query->all();With SearchableTrait:
SearchableTrait automatically creates and uses SearchableModel when no custom search class is specified:
class ProductController extends BaseController
{
use SearchableTrait;
public $modelClass = Product::class;
public $filterableAttributes = ['name', 'status', 'price', 'category_id'];
// SearchableModel is created automatically with these attributes
}Fluent Interface:
$searchModel = (new SearchableModel())
->defineAttribute('name', 'John')
->defineAttribute('status', 'active')
->setAttributeType('price', 'number');Custom error handler that converts exceptions with error details into structured JSON responses.
Location: src/common/errorHandler/CustomErrorHandler.php
Configuration:
// config/web.php
return [
'components' => [
'errorHandler' => [
'class' => 'common\errorHandler\CustomErrorHandler',
],
],
];Response Format:
{
"name": "Unprocessable Entity",
"message": {
"tags": {
"999": "Record not found"
}
},
"code": 0,
"status": 422
}Debug Mode Response (YII_DEBUG=true):
{
"name": "Unprocessable Entity",
"message": { ... },
"code": 0,
"status": 422,
"type": "common\\exceptions\\LinkException",
"file": "/app/src/common/behaviors/LinkBehavior.php",
"line": 256,
"stack-trace": [ ... ]
}Custom exception for relationship operations with detailed error collection.
Location: src/common/exceptions/LinkException.php
Usage:
use common\exceptions\LinkException;
// Throw with errors
throw new LinkException(
422,
'Link operation failed',
[
'tags' => [
'999' => 'Record not found',
'888' => 'Already linked'
]
]
);namespace api\modules\public\models;
use common\models\BaseActiveRecord;
use common\behaviors\LinkBehavior;
use common\behaviors\RelationsBehavior;
use common\traits\VirtualAttributesTrait;
class Post extends BaseActiveRecord
{
use VirtualAttributesTrait;
public static function tableName()
{
return 'post';
}
public function behaviors()
{
return [
'relations' => [
'class' => RelationsBehavior::class,
'deferUpdatesToLinkBehavior' => true,
],
'link' => [
'class' => LinkBehavior::class,
'rollbackOnError' => true,
],
];
}
public function rules()
{
return [
[['title', 'user_id'], 'required'],
[['title'], 'string', 'max' => 255],
[['content'], 'string'],
[['status'], 'in', 'range' => ['draft', 'published', 'archived']],
[['user_id'], 'integer'],
];
}
public function fields()
{
$fields = parent::fields();
$fields['authorName'] = fn($model) => $model->authorName ?? null;
return $fields;
}
public function extraFields()
{
return ['tags', 'author'];
}
public static function junctionFields()
{
return [
'tags' => ['weight'],
];
}
public function afterFind()
{
parent::afterFind();
$this->authorName = $this->author->fullName ?? null;
}
public function getTags()
{
return $this->hasMany(Tag::class, ['id' => 'tag_id'])
->viaTable('post_tag', ['post_id' => 'id']);
}
public function getPostTags()
{
return $this->hasMany(PostTag::class, ['post_id' => 'id']);
}
public function getAuthor()
{
return $this->hasOne(User::class, ['id' => 'user_id']);
}
}namespace api\modules\public\controllers;
use common\controllers\BaseController;
use common\traits\SearchableTrait;
class PostController extends BaseController
{
use SearchableTrait;
public $modelClass = 'api\modules\public\models\Post';
public $filterableAttributes = ['title', 'status', 'user_id', 'published_at'];
public function actions()
{
$actions = parent::actions();
$actions['index']['prepareDataProvider'] = [$this, 'prepareSearchDataProvider'];
return $actions;
}
protected function getSortConfig(): array
{
return [
'params' => \Yii::$app->request->getQueryParams(),
'defaultOrder' => ['published_at' => SORT_DESC],
'attributes' => ['title', 'status', 'published_at', 'created_at'],
];
}
}namespace api\modules\public\models;
use Yii;
use yii\web\IdentityInterface;
use common\models\BaseActiveRecord;
use common\behaviors\RelationsBehavior;
use common\traits\VirtualAttributesTrait;
class User extends BaseActiveRecord implements IdentityInterface
{
use VirtualAttributesTrait;
public static function tableName()
{
return '{{%user}}';
}
public function behaviors()
{
return [
'relations' => [
'class' => RelationsBehavior::class,
],
];
}
public function rules()
{
return [
[['email', 'password_hash'], 'required'],
[['email'], 'email'],
[['email'], 'string', 'max' => 255],
[['email'], 'unique'],
];
}
public function fields()
{
$fields = parent::fields();
// Exclude sensitive fields
unset($fields['password_hash'], $fields['auth_key'], $fields['access_token']);
// Add computed fields
$fields['fullName'] = fn($model) => $model->fullName ?? null;
$fields['roleNames'] = fn($model) => $model->roleNames ?? [];
return $fields;
}
public function extraFields()
{
return ['roles', 'posts'];
}
public function afterFind()
{
parent::afterFind();
$this->fullName = trim(($this->first_name ?? '') . ' ' . ($this->last_name ?? ''));
$this->roleNames = array_map(fn($r) => $r->name, $this->roles);
}
public function getRoles()
{
return $this->hasMany(Role::class, ['id' => 'role_id'])
->viaTable('user_role', ['user_id' => 'id']);
}
public function getUserRoles()
{
return $this->hasMany(UserRole::class, ['user_id' => 'id']);
}
public function getPosts()
{
return $this->hasMany(Post::class, ['user_id' => 'id']);
}
// IdentityInterface implementation...
public static function findIdentity($id)
{
return static::findOne(['id' => $id, 'status' => self::STATUS_ACTIVE]);
}
public static function findIdentityByAccessToken($token, $type = null)
{
return static::findOne(['access_token' => $token]);
}
public function getId() { return $this->id; }
public function getAuthKey() { return $this->auth_key; }
public function validateAuthKey($authKey) { return $this->auth_key === $authKey; }
public function validatePassword($password)
{
return Yii::$app->security->validatePassword($password, $this->password_hash);
}
public function setPassword($password)
{
$this->password_hash = Yii::$app->security->generatePasswordHash($password);
}
}### Create a post with tags
POST /api/posts
Content-Type: application/json
{
"title": "Getting Started with RESTstop",
"content": "This tutorial covers the basics...",
"status": "draft",
"user_id": 1,
"tags": {
"1": {"weight": 10},
"3": {"weight": 5}
}
}
### List posts with filtering
GET /api/posts?filter[status]=published&filter[title][like]=tutorial&sort=-published_at&per-page=10
### Get single post with expanded relations
GET /api/posts/1?expand=tags,author
### Update post and modify tags
PUT /api/posts/1
Content-Type: application/json
{
"status": "published",
"published_at": "2024-01-15T10:00:00Z",
"tags": [1, 5, -3]
}
### Get validation rules for form building
GET /api/posts/rules
### Delete a post
DELETE /api/posts/1### Text search (case-insensitive on PostgreSQL)
GET /api/users?filter[email][like]=@example.com
### Exact match
GET /api/posts?filter[status]=published
### Numeric comparison
GET /api/products?filter[price][gte]=10&filter[price][lte]=100
### Multiple values (IN clause)
GET /api/posts?filter[user_id][in][]=1&filter[user_id][in][]=2&filter[user_id][in][]=3
### Date range
GET /api/posts?filter[created_at][gte]=2024-01-01&filter[created_at][lt]=2024-02-01
### Combined with sorting and pagination
GET /api/posts?filter[status]=published&filter[user_id]=5&sort=-published_at,title&page=1&per-page=20### Single relation
GET /api/posts/1?expand=author
### Multiple relations
GET /api/posts/1?expand=author,tags
### Response includes junction data when configured
GET /api/posts/1?expand=tags
# Returns tags with 'weight' from post_tag junction tableInteractive API documentation is available via Swagger UI, auto-generated from controllers and models.
| Endpoint | Description |
|---|---|
GET /swagger |
Interactive Swagger UI |
GET /swagger/spec |
OpenAPI 3.0 JSON specification |
GET /swagger/yaml |
OpenAPI 3.0 YAML specification |
# Generate OpenAPI spec to stdout
./yii openapi/generate
# Generate as YAML
./yii openapi/generate --format=yaml
# Save to file
./yii openapi/generate --output=openapi.json
# Validate the specification
./yii openapi/validate
# Show spec info and statistics
./yii openapi/info
# Clear cached specification
./yii openapi/clear-cacheDefault configuration is in src/common/config/swagger.php. Project-specific overrides go in src/api/config/swagger-local.php:
// src/api/config/swagger-local.php
return [
'info' => [
'title' => 'My API',
'description' => 'API description',
'version' => '1.0.0',
],
'servers' => [
['url' => 'http://localhost:8080', 'description' => 'Development'],
['url' => 'https://api.example.com', 'description' => 'Production'],
],
];The Swagger implementation automatically documents:
- All REST endpoints (index, view, create, update, delete)
- Model schemas from validation
rules() - Request/response body schemas
- Filtering, sorting, and pagination parameters
- Security requirements (Bearer token)
- Expandable relations
No annotations required for standard REST controllers.
MIT