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+ autoloadDatabase\\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
Перевірка в браузері
-
На головній у nav з’являються Contact і
Stats — остання відкриває
/api/stats
(JSON з
contact_submissions_total, після seed — 2). - Відкрийте http://localhost:8089/contact, заповніть і надішліть форму (з CSRF-токеном) — зелений alert після збереження в БД.
- Оновіть /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-форми.
Усі кроки — Огляд.