Skip to content

Instantly share code, notes, and snippets.

@terabytesoftw
Created April 28, 2026 22:24
Show Gist options
  • Select an option

  • Save terabytesoftw/490f5509826337549b717f2ae2fc4f9c to your computer and use it in GitHub Desktop.

Select an option

Save terabytesoftw/490f5509826337549b717f2ae2fc4f9c to your computer and use it in GitHub Desktop.
Standalone Actions
==================
> Available since version 22.0.
This tutorial introduces the `Module::$actionMap` feature added in Yii 22.0. It explains how a single `yii\base\Action`
subclass can be routed directly without a hosting controller, walks through a complete CRUD example backed by ActiveRecord,
and clarifies how this coexists with the traditional controller approach.
The traditional controller approach is unchanged
------------------------------------------------
Everything you already know keeps working in 22.0:
* `app\controllers\PostController` extending `yii\web\Controller` with `actionIndex()`, `actionCreate()`,
`actionUpdate()`, `actionDelete()` methods.
* `Controller::actions()` registering reusable [[yii\base\Action]] subclasses such as `yii\rest\IndexAction`,
`yii\web\ErrorAction`, or your own.
* `Module::$controllerMap` and namespace-based controller discovery.
* All filters, behaviors, and CSRF/auth components attached at the controller level.
The new feature is purely additive. Existing applications upgrade without code changes; you only opt in where you
want the new pattern.
What `actionMap` adds
---------------------
`yii\base\Module::$actionMap` is a new property that maps a route segment directly to an `yii\base\Action` subclass.
When a request matches a key in the map, Yii dispatches the action **without instantiating any controller**:
```php
'actionMap' => [
'health' => app\UseCase\HealthCheckAction::class,
],
```
A request whose route resolves to `health` runs `HealthCheckAction::run()` directly. The action receives
its dependencies through the DI container (constructor injection for collaborators that live for the whole
request, method injection on `run()` for per-call services and route parameters). Filters declared in the
action's `behaviors()` (such as `yii\filters\AccessControl` or `yii\filters\VerbFilter`) participate in
the lifecycle just as they would on a controller.
The pattern is sometimes called **single-action controller**, **invokable handler**, or **vertical slice**.
The benefit is that one HTTP entry point lives in one file together with its own dependencies and
behaviors, which suits feature-organized codebases (DDD, use cases) without forcing you to abandon Yii's
component model.
When to use which
-----------------
Use the **traditional controller approach** when:
* Several actions naturally share state, filters, or layout (typical CRUD on a single resource where the
controller's `behaviors()` cover all actions).
* You rely on `view` rendering helpers from `yii\web\Controller::render()` (these still work inside
`Action::run()` via `$this->controller->render()`, but only when a controller hosts the action).
* You are extending an existing controller-based code base and consistency matters more than slicing.
Use **`actionMap` standalone actions** when:
* Each endpoint has its own dependencies and you want them injected explicitly per action.
* You want each feature folder (`app/UseCase/Posts/Create/`, …) to own its own action, form, service, and
view without a thin host controller.
* You are organizing an application as vertical slices or use cases.
Both styles can coexist in the same module. A route resolves through `actionMap` first; routes whose first
segment is not in the map fall through to the existing `controllerMap` and namespace pipeline unchanged.
A complete CRUD example
-----------------------
The rest of this tutorial builds a `posts` resource with six endpoints, all using `actionMap`. The same
example could be built with one `PostController` and six `actionXxx` methods — the patterns coexist.
### Folder layout
```
app/
UseCase/
Posts/
PostsIndexAction.php
PostsViewAction.php
PostsCreateAction.php
PostsUpdateAction.php
PostsDeleteAction.php
PostsSearchAction.php
PostForm.php
views/
index.php
view.php
form.php
models/
Post.php
migrations/
m250101_000000_create_post_table.php
config/
web.php
```
### 1. Migration
Migrations are unchanged. Use `safeUp()` and `safeDown()` per Yii conventions:
```php
use yii\db\Migration;
final class m250101_000000_create_post_table extends Migration
{
public function safeUp(): void
{
$this->createTable('{{%post}}', [
'id' => $this->primaryKey(),
'title' => $this->string(180)->notNull(),
'body' => $this->text()->notNull(),
'status' => $this->string(20)->notNull()->defaultValue('draft'),
'author_id' => $this->integer()->notNull(),
'created_at' => $this->integer()->notNull(),
'updated_at' => $this->integer()->notNull(),
]);
$this->createIndex('idx-post-status', '{{%post}}', 'status');
$this->createIndex('idx-post-author_id', '{{%post}}', 'author_id');
}
public function safeDown(): void
{
$this->dropTable('{{%post}}');
}
}
```
### 2. ActiveRecord model
`app/models/Post.php` follows standard Yii conventions:
```php
namespace app\models;
use yii\behaviors\TimestampBehavior;
use yii\db\ActiveRecord;
final class Post extends ActiveRecord
{
public static function tableName(): string
{
return '{{%post}}';
}
public function rules(): array
{
return [
[['title', 'body', 'author_id'], 'required'],
[['title'], 'string', 'max' => 180],
[['body'], 'string'],
[['status'], 'in', 'range' => ['draft', 'published', 'archived']],
[['author_id'], 'integer'],
];
}
public function attributeLabels(): array
{
return [
'id' => 'ID',
'title' => 'Title',
'body' => 'Body',
'status' => 'Status',
'author_id' => 'Author',
];
}
public function behaviors(): array
{
return [TimestampBehavior::class];
}
}
```
### 3. Form model (input validation)
`app/UseCase/Posts/PostForm.php` is a form-only model used by Create and Update. Keeping it separate from
the ActiveRecord lets you change the table without touching the input contract:
```php
namespace app\UseCase\Posts;
use yii\base\Model;
final class PostForm extends Model
{
public string $title = '';
public string $body = '';
public string $status = 'draft';
public function rules(): array
{
return [
[['title', 'body'], 'required'],
[['title'], 'string', 'max' => 180],
[['body'], 'string'],
[['status'], 'in', 'range' => ['draft', 'published', 'archived']],
];
}
public function attributeLabels(): array
{
return [
'title' => 'Title',
'body' => 'Body',
'status' => 'Status',
];
}
}
```
### 4. The actions
Each action is a [[yii\base\Action]] subclass that owns its `run()` method. Dependencies are injected by
the DI container.
#### `PostsIndexAction` — list with pagination
```php
namespace app\UseCase\Posts;
use app\models\Post;
use yii\base\Action;
use yii\data\ActiveDataProvider;
final class PostsIndexAction extends Action
{
public function run(): string
{
$provider = new ActiveDataProvider([
'query' => Post::find()->orderBy(['created_at' => SORT_DESC]),
'pagination' => ['pageSize' => 20],
]);
return $this->getModule()->view->render('@app/UseCase/Posts/views/index', [
'provider' => $provider,
], $this);
}
}
```
`$this->getModule()` returns the owning module when the action runs standalone (it falls back to the
controller's module when the action is hosted by a controller). Rendering goes through the module's view
component; you can equally inject [[yii\web\Response]] and return JSON without touching views.
#### `PostsViewAction` — show one record
```php
namespace app\UseCase\Posts;
use app\models\Post;
use yii\base\Action;
use yii\web\NotFoundHttpException;
final class PostsViewAction extends Action
{
public function run(int $id): string
{
$post = Post::findOne($id);
if ($post === null) {
throw new NotFoundHttpException('Post not found.');
}
return $this->getModule()->view->render('@app/UseCase/Posts/views/view', [
'post' => $post,
], $this);
}
}
```
The `int $id` parameter is filled from the route. The DI container resolves typed scalars from the
parameters passed to `runAction()` and typed services from the container.
#### `PostsCreateAction` — POST with form validation
```php
namespace app\UseCase\Posts;
use app\models\Post;
use Yii;
use yii\base\Action;
use yii\filters\AccessControl;
use yii\filters\VerbFilter;
use yii\web\Request;
use yii\web\Response;
use yii\web\User;
final class PostsCreateAction extends Action
{
public function behaviors(): array
{
return [
'access' => [
'class' => AccessControl::class,
'rules' => [['allow' => true, 'roles' => ['@']]],
],
'verbs' => [
'class' => VerbFilter::class,
'actions' => ['create' => ['POST']],
],
];
}
public function run(Request $request, Response $response, User $user, PostForm $form): Response|string
{
if (!$form->load($request->post()) || !$form->validate()) {
$response->statusCode = 422;
return $this->getModule()->view->render('@app/UseCase/Posts/views/form', [
'form' => $form,
], $this);
}
$post = new Post();
$post->setAttributes($form->getAttributes());
$post->author_id = (int) $user->getId();
if (!$post->save()) {
throw new \RuntimeException('Failed to persist post: ' . print_r($post->errors, true));
}
Yii::$app->session->setFlash('success', 'Post created.');
return $response->redirect(['posts/view', 'id' => $post->id]);
}
}
```
Three things to notice:
1. `behaviors()` is declared on the action itself. `AccessControl` and `VerbFilter` attach to the action's
`EVENT_BEFORE_ACTION` and run before `run()`.
2. `Request`, `Response`, `User`, and `PostForm` are typed parameters; the DI container resolves each of
them. `Request`, `Response`, and `User` are application components (resolved by name), while `PostForm`
is autowired by class.
3. The form is *separate* from the ActiveRecord. Validation runs on `PostForm`, then attributes are copied
to a `Post` instance. This keeps input validation independent of the persistence schema.
#### `PostsUpdateAction`, `PostsDeleteAction`, `PostsSearchAction`
These follow the same pattern. Update is a copy of Create with a leading lookup:
```php
public function run(int $id, Request $request, Response $response, PostForm $form): Response|string
{
$post = Post::findOne($id) ?? throw new NotFoundHttpException('Post not found.');
$form->setAttributes($post->getAttributes());
if ($request->isPut || $request->isPatch) {
if ($form->load($request->post()) && $form->validate()) {
$post->setAttributes($form->getAttributes());
$post->save(false);
return $response->redirect(['posts/view', 'id' => $post->id]);
}
}
return $this->getModule()->view->render('@app/UseCase/Posts/views/form', ['form' => $form], $this);
}
```
Delete is even smaller and only needs `VerbFilter` plus an `AccessControl` rule for the destroyer role.
Search is read-only and accepts `string $q` plus pagination parameters.
### 5. URL configuration
Yii's [[yii\web\UrlManager]] is unchanged; you just point clean URLs at the action IDs registered in
`actionMap`. Verb-prefixed rules give you proper REST routing:
```php
'urlManager' => [
'enablePrettyUrl' => true,
'showScriptName' => false,
'rules' => [
'GET,HEAD posts' => 'posts-index',
'GET posts/search' => 'posts-search',
'POST posts' => 'posts-create',
'GET posts/<id:\d+>' => 'posts-view',
'PUT,PATCH posts/<id:\d+>' => 'posts-update',
'DELETE posts/<id:\d+>' => 'posts-delete',
],
],
```
The right-hand side of each rule is a route string. Each route's first segment must match a key in
`actionMap`. The rest of the route is unused for standalone actions; placeholders like `<id:\d+>` are
captured and passed to `run()` as named parameters.
### 6. The `actionMap` registration
Add a single block in `config/web.php`:
```php
return [
// ...
'actionMap' => [
'posts-index' => app\UseCase\Posts\PostsIndexAction::class,
'posts-view' => app\UseCase\Posts\PostsViewAction::class,
'posts-create' => app\UseCase\Posts\PostsCreateAction::class,
'posts-update' => app\UseCase\Posts\PostsUpdateAction::class,
'posts-delete' => app\UseCase\Posts\PostsDeleteAction::class,
'posts-search' => app\UseCase\Posts\PostsSearchAction::class,
],
];
```
If you prefer per-action configuration (custom action ID, default values, …), use the array form:
```php
'posts-search' => [
'class' => app\UseCase\Posts\PostsSearchAction::class,
'pageSize' => 50,
],
```
This is identical to how [[yii\base\Controller::actions()]] accepts configurations.
### 7. Coexistence with controllers
The same application can declare both `controllerMap` and `actionMap`. A request whose first route segment
appears in `actionMap` is dispatched as a standalone action; everything else falls through to the
controller pipeline. There is no global switch; you migrate feature by feature when (and if) you want.
For example, suppose you keep the legacy `SiteController` for `/`, `/about`, and `/contact`, but move
`/posts/*` to standalone actions:
```php
'controllerMap' => [
// legacy controllers stay registered as before
'site' => app\controllers\SiteController::class,
],
'actionMap' => [
// new feature folder uses standalone actions
'posts-index' => app\UseCase\Posts\PostsIndexAction::class,
// ...
],
```
Routing is deterministic: `actionMap` is checked first, then `controllerMap`, then namespace discovery.
Things to keep in mind
----------------------
* `yii\base\Action::$controller` is `null` for standalone actions. Action classes that read
`$this->controller` directly are not standalone-compatible. The framework documents this on
[[yii\web\ViewAction]], [[yii\web\ErrorAction]], and [[yii\base\InlineAction]].
* `yii\filters\AccessRule::matchController()` accepts `null`. A rule with a non-empty `controllers`
constraint does not match a standalone action. Leave `controllers` empty (or omit the constraint) when
the rule should apply to standalone actions.
* `Yii::$app->controller` is **not** mutated when a standalone action runs. Code that reads
`Yii::$app->controller` mid-request still sees whatever value was set previously (typically `null`).
* CSRF, session, and authentication components are application-level. They keep working unchanged; nothing
about standalone actions disables them.
Summary
-------
* Existing controller-based code continues to work in 22.0 with no changes.
* `Module::$actionMap` adds an additive way to map a route directly to an [[yii\base\Action]] subclass.
* Each standalone action owns its own dependencies, behaviors, and lifecycle.
* Both styles coexist; you can adopt the new pattern feature by feature.
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment