Created
April 28, 2026 22:24
-
-
Save terabytesoftw/490f5509826337549b717f2ae2fc4f9c to your computer and use it in GitHub Desktop.
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| 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