6. База даних

Web-форма і JSON API вже валідують contact, але після успіху дані зникають разом із response. Для демо це ок; для реального додатку — ні. На цьому кроці з’являється персистентність: Eloquent Capsule через addDatabase(), міграції, seeders і консольні команди db:migrate / db:rollback / db:seed.

Це також точка злиття треків: web (View, Session) і api (routes/api.php) знову в одному Bootstrap. Підключення до MySQL задаємо явно (literal array + getenv() для Docker) — без Config/PathManager (пізніше). Eloquent Model на цьому кроці не вводимо: достатньо query builder через DatabaseInterface::capsule(), щоб не змішувати «як підключити БД» з «як моделювати домен».

Що додається і навіщо

  • addDatabase() — Capsule, DatabaseInterface, міграції, seeders: єдине місце, де з’являється з’єднання з MySQL
  • addConsole() — db:migrate, db:rollback, db:seed, migration:list, seeder:list: схему міняємо з CLI, не «вручну в phpMyAdmin»
  • database/migrations/…CreateContactSubmissionsTable.php — схема таблиці версіонована разом із кодом
  • database/seeders/ContactSubmissionsSeeder.php + autoload Database\\Seeders\\ — демо-дані для /api/stats без ручного INSERT
  • routes/api.php разом із web.php; CSRF лише на web-групі — інакше API знову отримає 302
  • Web і API POST зберігають у contact_submissions; GET /api/stats — лічильник, щоб перевірити з’єднання без UI
  • MySQL 8.4 у compose (порт хоста 3366); bin/console.php — той самий bootstrap, що й HTTP

composer.json

Після merge API знову потрібен extension-json (його прибирали на чистому api-checkout, на web його не було). Autoload для seeders — окремий PSR-4 namespace (Database\\Seeders\\ → database/seeders/): seeders не живуть у Concept\, щоб не змішувати application code з database tooling.

{
    "require": {
        "php": "^8.4",
        "php-concept/core-2": "^1.0",
        "php-concept/stack": "^1.0",
        "php-concept/extension-json": "^1.0",
        "twbs/bootstrap": "^5.3",
        "twbs/bootstrap-icons": "^1.13"
    },
    "autoload": {
        "psr-4": {
            "Concept\\": "src/",
            "Database\\Seeders\\": "database/seeders/"
        }
    }
}

ApplicationStackBootstrap.php

Database реєструємо до Http, щоб controllers могли інжектити DatabaseInterface з першого запиту. Seeders — на Database brick (setSeeders), не на Console: Console лише запускає команди, а список «що можна засіяти» належить БД-шару. Команди — на Console builder.

DSN з getenv() — компроміс цього кроку: Docker передає host db, локально можна fallback на 127.0.0.1. Повноцінний Config з’явиться пізніше; зараз важливо, що значення явні в Bootstrap, а не «магічно» зчитуються Stack-ом. Повний API — Database brick, CLI — Console brick.

$stack->addValidation();

$stack->addDatabase()
    ->setConnection([
        'driver' => 'mysql',
        'host' => getenv('MYSQL_HOST') ?: '127.0.0.1',
        'port' => (int) (getenv('MYSQL_PORT') ?: 3306),
        'database' => getenv('MYSQL_DATABASE') ?: 'concept_recipes',
        'username' => getenv('MYSQL_USER') ?: 'root',
        'password' => getenv('MYSQL_PASSWORD') ?: 'root',
        'charset' => 'utf8mb4',
        'collation' => 'utf8mb4_unicode_ci',
        'prefix' => '',
    ])
    ->setMigrations([$rootPath . 'database/migrations'])
    ->setMigrationsTable('migrations')
    ->setSeeders([
        ContactSubmissionsSeeder::class,
    ]);

$stack->addSession()
    ->setHandlerFactory(function () use ($rootPath) {
        return new NativeFileSessionHandler($rootPath . 'storage/sessions');
    })
    ->withCsrf();

$stack->addHttp()
    ->setRoutes([
        $rootPath . 'routes/web.php',
        $rootPath . 'routes/api.php',
    ]);

$stack->addView()
    ->withTwig()
    ->setViewsPath($rootPath . 'resources/views');

$stack->addConsole()
    ->setName('Recipes')
    ->setVersion('1.0.0')
    ->setCommands([
        DbMigrateCommand::class,
        DbRollbackCommand::class,
        DbMigrationListCommand::class,
        DbSeedCommand::class,
        DbSeederListCommand::class,
    ]);

database/migrations/…CreateContactSubmissionsTable.php

Міграція — щоб схему можна було відтворити на новому середовищі однією командою, а не «згадати CREATE TABLE». Анонімний клас — convention Illuminate. up() створює таблицю для збережених contact; down() потрібен для db:rollback, інакше відкат був би одностороннім.

Ім’я файлу з префіксом V і датою — convention extension-database-eloquent: порядок застосування визначається іменем, не «хто перший у каталозі в IDE».

<?php declare(strict_types=1);

use Illuminate\Database\Migrations\Migration;
use Illuminate\Database\Schema\Blueprint;
use Illuminate\Database\Capsule\Manager as CapsuleManager;

return new class extends Migration
{
    public function up(): void
    {
        CapsuleManager::schema()->create('contact_submissions', function (Blueprint $table): void {
            $table->increments('id');
            $table->string('name', 100);
            $table->string('email', 255);
            $table->text('message');
            $table->timestamp('created_at')->useCurrent();
        });
    }

    public function down(): void
    {
        CapsuleManager::schema()->dropIfExists('contact_submissions');
    }
};

database/seeders/ContactSubmissionsSeeder.php

Seeder потрібен, щоб /api/stats і демо одразу мали рядки, без ручного INSERT після кожного db:migrate. Truncate + insert робить db:seed ідемпотентним для демо: повторний запуск не плодить дублікати. Реєстрація класу — у setSeeders([...]), не в Console: команда лише викликає вже зареєстровані seeders.

<?php declare(strict_types=1);

namespace Database\Seeders;

use Concept\Extensions\DatabaseEloquent\Contracts\SeederInterface;
use Illuminate\Database\Capsule\Manager as CapsuleManager;
use Illuminate\Database\Seeder;

final class ContactSubmissionsSeeder extends Seeder implements SeederInterface
{
    public function run(): void
    {
        $table = CapsuleManager::schema()->getConnection()->table('contact_submissions');
        $table->truncate();

        $table->insert([
            [
                'name' => 'Demo User',
                'email' => 'demo@example.com',
                'message' => 'Seeded contact submission for the recipes demo.',
                'created_at' => date('Y-m-d H:i:s'),
            ],
            [
                'name' => 'Anna',
                'email' => 'anna@example.com',
                'message' => 'Hello from the database seeder.',
                'created_at' => date('Y-m-d H:i:s'),
            ],
        ]);
    }
}

ContactController.php

Після успішної валідації — той самий insert, що в API: один сховище, два transport-и. Інжектимо DatabaseInterface поруч із View / CSRF / Flash — controller лишається місцем «бізнес-історії» сторінки, а не місцем з’єднання PDO.

final class ContactController
{
    public function __construct(
        private readonly ViewResponseFactoryInterface $view,
        private readonly FormRequestFactoryInterface $formRequests,
        private readonly CsrfTokenManagerInterface $csrf,
        private readonly FlashBagInterface $flashBag,
        private readonly DatabaseInterface $database,
    ) {}

    public function submit(ServerRequestInterface $request): ResponseInterface
    {
        $form = $this->formRequests->make(ContactFormRequest::class, $request);

        if (!$form->validate()) {
            return $this->view->create('contact', [
                ...$this->formViewData(),
                'errors' => $form->errors(),
                'old' => $form->all(),
                'success' => false,
                'validated' => null,
            ], HttpStatusCode::UNPROCESSABLE_ENTITY);
        }

        $validated = $form->validated();

        $this->database->capsule()->table('contact_submissions')->insert([
            'name' => $validated['name'],
            'email' => $validated['email'],
            'message' => $validated['message'],
            'created_at' => date('Y-m-d H:i:s'),
        ]);

        return $this->view->create('contact', [
            ...$this->formViewData(),
            'errors' => [],
            'old' => [],
            'success' => true,
            'validated' => $validated,
        ]);
    }
}

ApiController.php

stats() — швидка перевірка з’єднання і лічильник без UI: зручно після migrate/seed і в CI. contact() — validate + insert + JSON (saved: true), щоб клієнт відрізняв «валідація ок, але не записали» від успішного збереження. Без Eloquent Model: query builder достатньо для цього кроку.

final class ApiController
{
    public function __construct(
        private readonly ResponseFactoryInterface $response,
        private readonly FormRequestFactoryInterface $formRequests,
        private readonly DatabaseInterface $database,
    ) {}

    public function ping(): ResponseInterface
    {
        return $this->response->jsonSuccess(['message' => 'pong']);
    }

    public function stats(): ResponseInterface
    {
        $submissions = $this->database->capsule()->table('contact_submissions');

        return $this->response->jsonSuccess([
            'connection' => 'ok',
            'contact_submissions_total' => $submissions->count(),
        ]);
    }

    public function contact(ServerRequestInterface $request): ResponseInterface
    {
        $form = $this->formRequests->make(ContactFormRequest::class, $request);

        if (!$form->validate()) {
            return $this->response->jsonError(
                'Validation failed',
                HttpStatusCode::UNPROCESSABLE_ENTITY,
                $form->errors(),
            );
        }

        $validated = $form->validated();

        $this->database->capsule()->table('contact_submissions')->insert([
            'name' => $validated['name'],
            'email' => $validated['email'],
            'message' => $validated['message'],
            'created_at' => date('Y-m-d H:i:s'),
        ]);

        return $this->response->jsonSuccess([
            'validated' => $validated,
            'saved' => true,
        ]);
    }
}

routes/web.php

CSRF-middleware з кроку 5 були на root router — після merge ламали POST /api/contact (302 замість JSON). Тому лише група HTML-маршрутів: API лишається зі своїм JSON-ланцюжком. Це той самий принцип, що на кроці 7: middleware належать surface, не «всьому додатку».

<?php declare(strict_types=1);

use Concept\App\Controllers\ContactController;
use Concept\App\Controllers\IndexController;
use Concept\App\Middleware\StorePreviousUrlMiddleware;
use Concept\Extensions\Csrf\Middleware\HandleCsrfExceptionMiddleware;
use Concept\Extensions\Csrf\Middleware\VerifyCsrfTokenMiddleware;
use League\Route\RouteGroup;
use League\Route\Router;

/** @var Router $router */
$router->group('', function (RouteGroup $router): void {
    $router->get('/', [IndexController::class, 'index'])->setName('home');

    $router->get('/contact', [ContactController::class, 'show'])->setName('contact');
    $router->post('/contact', [ContactController::class, 'submit'])->setName('contact.submit');
})->lazyMiddlewares([
    StorePreviousUrlMiddleware::class,
    HandleCsrfExceptionMiddleware::class,
    VerifyCsrfTokenMiddleware::class,
]);

routes/api.php

Той самий JSON-ланцюжок, що на кроці 4, плюс GET /api/stats для перевірки БД. Окремий файл — щоб web і api middleware не змішувались навіть у спільному Bootstrap.

<?php declare(strict_types=1);

use Concept\App\Controllers\ApiController;
use Concept\Extensions\Json\Middleware\ForceJsonResponseMiddleware;
use Concept\Extensions\Json\Middleware\ParseJsonBodyMiddleware;
use League\Route\RouteGroup;
use League\Route\Router;

/** @var Router $router */
$router->group('/api', function (RouteGroup $router): void {
    $router->get('/ping', [ApiController::class, 'ping'])->setName('api.ping');
    $router->get('/stats', [ApiController::class, 'stats'])->setName('api.stats');
    $router->post('/contact', [ApiController::class, 'contact'])->setName('api.contact');
})->lazyMiddlewares([
    ParseJsonBodyMiddleware::class,
    ForceJsonResponseMiddleware::class,
]);

bin/console.php

Той самий bootstrap/app.php, що й HTTP — Stack уже зареєстрував ConsoleApplication з командами (Console brick). Окремий bootstrap для CLI не потрібен: інакше легко роз’їхатись із connection-налаштуваннями. Запускай всередині контейнера (на хості часто немає pdo_mysql і host db недоступний).

<?php declare(strict_types=1);

use Concept\Core\App;
use Symfony\Component\Console\Application as ConsoleApplication;

require __DIR__ . '/../vendor/autoload.php';

/** @var App $app */
$app = require __DIR__ . '/../bootstrap/app.php';

/** @var ConsoleApplication $console */
$console = $app->getContainer()->get(ConsoleApplication::class);

exit($console->run());

docker-compose.yml.example

Сервіс db з’являється саме тут: без нього addDatabase() нема до чого підключатись у демо. Змінні MYSQL_* на app збігаються з тим, що читає Bootstrap через getenv(). Порт хоста 3366 — щоб не конфліктувати з локальним MySQL на 3306.

services:
  app:
    build: .
    container_name: concept_recipes
    restart: unless-stopped
    user: "${UID:-1000}:${GID:-1000}"
    ports:
      - "8089:80"
    volumes:
      - .:/var/www/html
      - ./docker/apache/vhost.conf:/etc/apache2/sites-available/000-default.conf
      - ./docker/php/error-reporting.ini:/usr/local/etc/php/conf.d/error-reporting.ini
    environment:
      - MYSQL_HOST=db
      - MYSQL_DATABASE=concept_recipes
      - MYSQL_USER=root
      - MYSQL_PASSWORD=root
    depends_on:
      - db

  db:
    image: mysql:8.4
    command: --mysql-native-password=ON
    container_name: concept_recipes_db
    restart: unless-stopped
    environment:
      MYSQL_DATABASE: concept_recipes
      MYSQL_ROOT_PASSWORD: root
    ports:
      - "3366:3306"

Запуск і перевірка

Тег step-06-database. Після migrate і seed GET /api/stats показує contact_submissions_total: 2; валідний POST /api/contact збільшує лічильник на 1.

Запуск

git checkout step-06-database
rm -rf storage/cache storage/sessions storage/logs
composer install
cp docker-compose.yml.example docker-compose.yml
docker compose up -d --build

docker compose exec app php bin/console.php db:migrate
docker compose exec app php bin/console.php db:seed

Перевірка в браузері

  1. На головній у nav з’являються Contact і Stats — остання відкриває /api/stats (JSON з contact_submissions_total, після seed — 2).
  2. Відкрийте http://localhost:8089/contact, заповніть і надішліть форму (з CSRF-токеном) — зелений alert після збереження в БД.
  3. Оновіть /api/stats — лічильник має зрости.

Перевірка з CLI

docker compose exec app php bin/console.php migration:list
docker compose exec app php bin/console.php seeder:list

curl -s http://localhost:8089/api/stats
# contact_submissions_total: 2

curl -s -X POST http://localhost:8089/api/contact \
  -H 'Content-Type: application/json' \
  -d '{"name":"Anna","email":"anna@example.com","message":"Hello there friend"}'
# saved: true

curl -s http://localhost:8089/api/stats
# contact_submissions_total: 3

# rollback / знову:
# docker compose exec app php bin/console.php db:rollback
# docker compose exec app php bin/console.php db:migrate
# docker compose exec app php bin/console.php db:seed

Ланцюжок

CLI і HTTP ділять Capsule. Web і API ділять таблицю, але не middleware: CSRF лишається на HTML-групі.

db:migrate → contact_submissions
db:seed → ContactSubmissionsSeeder (truncate + insert)
db:rollback → down() останнього batch

POST /contact (web)
  → CSRF group → FormRequest → insert → HTML 200
POST /api/contact
  → ParseJsonBody → FormRequest → insert → JSON 200
GET /api/stats → count → JSON

Крок 7 — Обробка помилок (addErrorHandling(), Whoops, FormRequest type-hint): закриваємо «погані» шляхи і прибираємо ручний validate з web-форми. Усі кроки — Огляд.