DEV Community

Cover image for Building Your First CRUD App in Yii3: A Step-by-Step Guide for Beginners
Yuri Neves
Yuri Neves

Posted on

Building Your First CRUD App in Yii3: A Step-by-Step Guide for Beginners

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.

The finished app: a list of posts with Edit and Delete actions

Step 1: Create the project

composer create-project yiisoft/app crud-app
cd crud-app
cp .env.example .env
./yii serve
Enter fullscreen mode Exit fullscreen mode

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
Enter fullscreen mode Exit fullscreen mode

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
Enter fullscreen mode Exit fullscreen mode

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,
    ),
];
Enter fullscreen mode Exit fullscreen mode

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'],
    ],
];
Enter fullscreen mode Exit fullscreen mode

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"
Enter fullscreen mode Exit fullscreen mode

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');
    }
}
Enter fullscreen mode Exit fullscreen mode

Run it:

./yii migrate:up
Enter fullscreen mode Exit fullscreen mode

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();
    }
}
Enter fullscreen mode Exit fullscreen mode

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 = '';
}
Enter fullscreen mode Exit fullscreen mode

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'));
    }
}
Enter fullscreen mode Exit fullscreen mode

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'),
                ),
        ),
];
Enter fullscreen mode Exit fullscreen mode

{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 ?>
Enter fullscreen mode Exit fullscreen mode

$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() ?>
Enter fullscreen mode Exit fullscreen mode

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',
]) ?>
Enter fullscreen mode Exit fullscreen mode
<?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',
]) ?>
Enter fullscreen mode Exit fullscreen mode

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.

Empty state with a

Submit a short title with an empty body, and the rules from PostForm kick in:

Validation errors under each field

Edit reuses the same form, filled with the current data:

Edit form filled with the current post

Delete asks for confirmation first:

List after deleting a post, showing

Here's the path of one request, from the form submit to the redirect:

Request flow: index.php, middleware, router, controller, form, repository, SQLite, 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)