I've been using Yii3 in a few side projects, and most "first CRUD" tutorials out there are still about Laravel. So here's one for Yii3.
If you come from Yii2, forget Yii::$app. Yii3 is PSR all the way down, and everything reaches your classes through the constructor. It's more explicit, and that actually makes it easier to follow, because nothing happens behind your back.
We'll build a small post manager with list, create, edit and delete. It uses SQLite and PHP's built-in server, so all you need is PHP 8.2+ (with pdo_sqlite) and Composer.
Step 1: Create the project
composer create-project yiisoft/app crud-app
cd crud-app
cp .env.example .env
./yii serve
Open http://127.0.0.1:8080 and you should see the Yii3 welcome page. On Windows, use yii.bat instead of ./yii.
Copying .env puts the app in dev mode, so errors come with a stack trace instead of a blank 500 page.
These are the folders we'll touch:
config/common/di/ # services, every file here is loaded
config/common/routes.php
config/console/params.php
src/ # your code, namespace App\
runtime/ # logs, cache and our SQLite file
Step 2: Install the database and form packages
The template doesn't ship with a database layer or forms, so we add them:
composer require yiisoft/db-sqlite yiisoft/db-migration yiisoft/cache yiisoft/form-model
db-sqlite gives you the connection and the query builder. db-migration adds the ./yii migrate:* commands. form-model brings form classes, validation and field widgets. cache is only there because the connection wants a schema cache.
Step 3: Configure the database connection
In Yii3, configuring something means telling the DI container how to build it. Create config/common/di/db.php:
<?php
declare(strict_types=1);
use Yiisoft\Aliases\Aliases;
use Yiisoft\Cache\ArrayCache;
use Yiisoft\Db\Cache\SchemaCache;
use Yiisoft\Db\Connection\ConnectionInterface;
use Yiisoft\Db\Sqlite\Connection;
use Yiisoft\Db\Sqlite\Driver;
return [
SchemaCache::class => [
'__construct()' => [new ArrayCache()],
],
ConnectionInterface::class => static fn (Aliases $aliases, SchemaCache $schemaCache) => new Connection(
new Driver('sqlite:' . $aliases->get('@runtime/database.sqlite')),
$schemaCache,
),
];
From now on, any class that asks for ConnectionInterface in its constructor gets this connection.
Then tell the migration tool where migrations live. In config/console/params.php:
<?php
declare(strict_types=1);
return [
'yiisoft/yii-console' => [
'commands' => require __DIR__ . '/commands.php',
],
'yiisoft/db-migration' => [
'newMigrationNamespace' => 'App\Migration',
'sourceNamespaces' => ['App\Migration'],
],
];
Step 4: Create the post table
mkdir -p src/Migration
./yii migrate:create post --command=table --fields="title:string(200):notNull,body:text:notNull,created_at:datetime:notNull,updated_at:datetime:notNull"
The generator writes something like src/Migration/M261008033620CreatePostTable.php:
final class M261008033620CreatePostTable implements RevertibleMigrationInterface, TransactionalMigrationInterface
{
public function up(MigrationBuilder $b): void
{
$columnBuilder = $b->columnBuilder();
$b->createTable('post', [
'id' => $columnBuilder::primaryKey(),
'title' => $columnBuilder::string(200)->notNull(),
'body' => $columnBuilder::text()->notNull(),
'created_at' => $columnBuilder::datetime()->notNull(),
'updated_at' => $columnBuilder::datetime()->notNull(),
]);
}
public function down(MigrationBuilder $b): void
{
$b->dropTable('post');
}
}
Run it:
./yii migrate:up
Now runtime/database.sqlite exists, with the table in it.
Step 5: The repository
Yii3 doesn't push an ORM on you. For something this size, a small class using the query builder is enough. Create src/Post/PostRepository.php:
<?php
declare(strict_types=1);
namespace App\Post;
use Yiisoft\Db\Connection\ConnectionInterface;
final readonly class PostRepository
{
public function __construct(
private ConnectionInterface $db,
) {}
/**
* @return array[] Newest posts first.
*/
public function findAll(): array
{
return $this->db->select()->from('post')->orderBy(['id' => SORT_DESC])->all();
}
public function findById(int $id): ?array
{
return $this->db->select()->from('post')->where(['id' => $id])->one();
}
public function create(string $title, string $body): int
{
$now = date('Y-m-d H:i:s');
$this->db->createCommand()->insert('post', [
'title' => $title,
'body' => $body,
'created_at' => $now,
'updated_at' => $now,
])->execute();
return (int) $this->db->getLastInsertId();
}
public function update(int $id, string $title, string $body): void
{
$this->db->createCommand()->update('post', [
'title' => $title,
'body' => $body,
'updated_at' => date('Y-m-d H:i:s'),
], ['id' => $id])->execute();
}
public function delete(int $id): void
{
$this->db->createCommand()->delete('post', ['id' => $id])->execute();
}
}
This class needs no config. The container sees ConnectionInterface in the constructor and passes the connection in. Values go through bound parameters, so SQL injection isn't a concern here. If you'd rather have Active Record, there's yiisoft/active-record.
Step 6: The form
The form is a class, and the validation rules are attributes on its properties. Create src/Web/Post/PostForm.php:
<?php
declare(strict_types=1);
namespace App\Web\Post;
use Yiisoft\FormModel\FormModel;
use Yiisoft\Validator\Rule\Length;
use Yiisoft\Validator\Rule\Required;
final class PostForm extends FormModel
{
#[Required]
#[Length(min: 3, max: 200, skipOnEmpty: true)]
public string $title = '';
#[Required]
#[Length(min: 10, skipOnEmpty: true)]
public string $body = '';
}
Without skipOnEmpty, an empty field would show two errors, "cannot be blank" and the length one.
Step 7: The controller
Create src/Web/Post/PostController.php:
<?php
declare(strict_types=1);
namespace App\Web\Post;
use App\Post\PostRepository;
use App\Web\NotFound\NotFoundHandler;
use Psr\Http\Message\ResponseFactoryInterface;
use Psr\Http\Message\ResponseInterface;
use Psr\Http\Message\ServerRequestInterface;
use Yiisoft\FormModel\FormHydrator;
use Yiisoft\Http\Header;
use Yiisoft\Http\Status;
use Yiisoft\Router\HydratorAttribute\RouteArgument;
use Yiisoft\Router\UrlGeneratorInterface;
use Yiisoft\Session\Flash\FlashInterface;
use Yiisoft\Yii\View\Renderer\WebViewRenderer;
final readonly class PostController
{
private WebViewRenderer $viewRenderer;
public function __construct(
WebViewRenderer $viewRenderer,
private PostRepository $posts,
private FormHydrator $formHydrator,
private ResponseFactoryInterface $responseFactory,
private UrlGeneratorInterface $urlGenerator,
private FlashInterface $flash,
private NotFoundHandler $notFoundHandler,
) {
$this->viewRenderer = $viewRenderer->withViewPath(__DIR__ . '/views');
}
public function index(): ResponseInterface
{
return $this->viewRenderer->render('index', [
'posts' => $this->posts->findAll(),
'success' => $this->flash->get('success'),
]);
}
public function create(ServerRequestInterface $request): ResponseInterface
{
$form = new PostForm();
if ($this->formHydrator->populateFromPostAndValidate($form, $request)) {
$this->posts->create($form->title, $form->body);
$this->flash->set('success', 'Post created!');
return $this->redirectToIndex();
}
return $this->viewRenderer->render('create', ['form' => $form]);
}
public function edit(ServerRequestInterface $request, #[RouteArgument('id')] int $id): ResponseInterface
{
$post = $this->posts->findById($id);
if ($post === null) {
return $this->notFoundHandler->handle($request);
}
$form = new PostForm();
$form->title = $post['title'];
$form->body = $post['body'];
if ($this->formHydrator->populateFromPostAndValidate($form, $request)) {
$this->posts->update($id, $form->title, $form->body);
$this->flash->set('success', 'Post updated!');
return $this->redirectToIndex();
}
return $this->viewRenderer->render('edit', ['form' => $form, 'id' => $id]);
}
public function delete(#[RouteArgument('id')] int $id): ResponseInterface
{
$this->posts->delete($id);
$this->flash->set('success', 'Post deleted.');
return $this->redirectToIndex();
}
private function redirectToIndex(): ResponseInterface
{
return $this->responseFactory
->createResponse(Status::FOUND)
->withHeader(Header::LOCATION, $this->urlGenerator->generate('post/index'));
}
}
create and edit handle both GET and POST. populateFromPostAndValidate() returns false when there's nothing posted yet or when validation fails, and in both cases we just render the form again. On a failed submit, the form already holds what the user typed plus the errors.
#[RouteArgument('id')] pulls {id} from the URL straight into the method as an int. The redirect is a plain PSR-7 response with a 302 status and a Location header.
Step 8: Routes
In config/common/routes.php, add a /posts group next to the home route:
<?php
declare(strict_types=1);
use App\Web;
use App\Web\Post\PostController;
use Yiisoft\Http\Method;
use Yiisoft\Router\Group;
use Yiisoft\Router\Route;
return [
Group::create()
->routes(
Route::get('/')
->action(Web\HomePage\Action::class)
->name('home'),
Group::create('/posts')
->namePrefix('post/')
->routes(
Route::get('')
->action([PostController::class, 'index'])
->name('index'),
Route::methods([Method::GET, Method::POST], '/create')
->action([PostController::class, 'create'])
->name('create'),
Route::methods([Method::GET, Method::POST], '/{id:\d+}/edit')
->action([PostController::class, 'edit'])
->name('edit'),
Route::post('/{id:\d+}/delete')
->action([PostController::class, 'delete'])
->name('delete'),
),
),
];
{id:\d+} only matches numbers, so /posts/abc/edit is a 404 before it reaches the controller. Delete is POST only, so a GET to it returns 405.
Step 9: Views
Views are plain PHP. They go in src/Web/Post/views/.
index.php:
<?php
declare(strict_types=1);
use Yiisoft\Html\Html;
/**
* @var Yiisoft\View\WebView $this
* @var Yiisoft\Router\UrlGeneratorInterface $urlGenerator
* @var Yiisoft\Yii\View\Renderer\Csrf $csrf
* @var array[] $posts
* @var string|null $success
*/
$this->setTitle('Posts');
?>
<div class="page-header">
<h1>Posts</h1>
<a class="btn" href="<?= $urlGenerator->generate('post/create') ?>">+ New post</a>
</div>
<?php if ($success): ?>
<div class="alert"><?= Html::encode($success) ?></div>
<?php endif ?>
<?php if ($posts === []): ?>
<p class="empty">No posts yet. Go write the first one!</p>
<?php else: ?>
<table class="table">
<thead>
<tr>
<th>#</th>
<th>Title</th>
<th>Updated</th>
<th></th>
</tr>
</thead>
<tbody>
<?php foreach ($posts as $post): ?>
<tr>
<td><?= $post['id'] ?></td>
<td><?= Html::encode($post['title']) ?></td>
<td><?= Html::encode($post['updated_at']) ?></td>
<td class="actions">
<a href="<?= $urlGenerator->generate('post/edit', ['id' => $post['id']]) ?>">Edit</a>
<?= Html::form()
->post($urlGenerator->generate('post/delete', ['id' => $post['id']]))
->csrf($csrf)
->addAttributes(['onsubmit' => "return confirm('Delete this post?')"])
->open() ?>
<button type="submit" class="link-danger">Delete</button>
<?= Html::form()->close() ?>
</td>
</tr>
<?php endforeach ?>
</tbody>
</table>
<?php endif ?>
$urlGenerator and $csrf are available in every view, because the template config injects them. Html::encode() is there to escape whatever the user typed.
_form.php, shared by create and edit:
<?php
declare(strict_types=1);
use Yiisoft\FormModel\Field;
use Yiisoft\Html\Html;
/**
* @var Yiisoft\Router\UrlGeneratorInterface $urlGenerator
* @var Yiisoft\Yii\View\Renderer\Csrf $csrf
* @var App\Web\Post\PostForm $form
* @var string $action
* @var string $submitLabel
*/
?>
<?= Html::form()->post($action)->csrf($csrf)->open() ?>
<?= Field::text($form, 'title') ?>
<?= Field::textarea($form, 'body')->addInputAttributes(['rows' => 8]) ?>
<div class="form-actions">
<?= Html::submitButton($submitLabel, ['class' => 'btn']) ?>
<a href="<?= $urlGenerator->generate('post/index') ?>">Cancel</a>
</div>
<?= Html::form()->close() ?>
Field::text($form, 'title') prints the label, the input with its current value, and the validation errors.
create.php and edit.php just set the title and include the form:
<?php // create.php
declare(strict_types=1);
/**
* @var Yiisoft\View\WebView $this
* @var Yiisoft\Router\UrlGeneratorInterface $urlGenerator
* @var App\Web\Post\PostForm $form
*/
$this->setTitle('New post');
?>
<h1>New post</h1>
<?= $this->render('_form', [
'form' => $form,
'action' => $urlGenerator->generate('post/create'),
'submitLabel' => 'Create',
]) ?>
<?php // edit.php
declare(strict_types=1);
/**
* @var Yiisoft\View\WebView $this
* @var Yiisoft\Router\UrlGeneratorInterface $urlGenerator
* @var App\Web\Post\PostForm $form
* @var int $id
*/
$this->setTitle('Edit post');
?>
<h1>Edit post #<?= $id ?></h1>
<?= $this->render('_form', [
'form' => $form,
'action' => $urlGenerator->generate('post/edit', ['id' => $id]),
'submitLabel' => 'Save',
]) ?>
The header and footer come from the template layout in src/Web/Shared/Layout/Main/layout.php. For the styles, I added a few rules to the end of assets/main/site.css. They're in the repo if you want the same look.
Step 10: Try it
With ./yii serve running, go to http://127.0.0.1:8080/posts.
Submit a short title with an empty body, and the rules from PostForm kick in:
Edit reuses the same form, filled with the current data:
Delete asks for confirmation first:
Here's the path of one request, from the form submit to the redirect:
If a form ever returns 422, the _csrf field is missing. Every POST form needs ->csrf($csrf).
Next steps
From here, I'd add pagination with yiisoft/data and yiisoft/data-db, then login with yiisoft/user. Moving to MySQL or PostgreSQL only means changing config/common/di/db.php, and the repository stays the same.
The code is at yurineves92/crud-yii3.
If you've used Yii2, I'm curious what feels different to you in Yii3.






Top comments (0)