1. Перший HTTP-запит

Хочеться одразу HTML, форми, сесію — але тоді не видно, де кінчається «просто відповісти на URL» і починається решта. Тому перший крок — найменша робоча збірка: один маршрут GET /, відповідь Hello, world!. Без config, PathManager, View, Session чи Database — щоб побачити HTTP-цикл окремо від усього іншого.

Лише Core + extension Http, зібрані через Stack. Йдемо від простого до складнішого — п’ять файлів у порядку, як їх читає запит. На кожному — спочатку навіщо файл існує, потім код.

Що входить у збірку і навіщо

  • addHttp() — ізолює HTTP-шар (Router, kernel, response helpers) у окрему capability: без цього виклику в контейнері немає маршрутизації. Див. Http brick
  • HttpServiceProvider — ResponseFactory, UrlGenerator з’являються лише після цього brick; controller не тягне «глобальні» хелпери з повітря
  • HttpKernelServiceProvider — підключає Router і route file без прихованих «auto routes»: шлях до routes ви задаєте самі
  • Два core resolvers: ServerRequestArgumentResolver, RouteParameterArgumentResolver — явний ланцюжок аргументів handler-а (request / параметри URL), без магії

1. public/index.php

Web-сервер має один обов’язок: завантажити додаток, отримати response і віддати його клієнту. Якщо зібрати Stack прямо тут — кожна зміна capabilities чіпатиме SAPI-файл, а тести без HTTP emitter стануть незручними. Тому entry point тримаємо тонким: autoload → app → handle → emit.

<?php declare(strict_types=1);

use Concept\Core\App;
use Laminas\HttpHandlerRunner\Emitter\SapiEmitter;

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

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

$response = $app->handle();

(new SapiEmitter)->emit($response);

App::handle() бере request з globals, передає в Router і повертає PSR-7 response. Уся збірка сервісів — у bootstrap/app.php: цей файл можна не чіпати, коли додасте View чи Session.

2. bootstrap/app.php

Розділяємо створення App і реєстрацію providers: той самий bootstrap можна викликати з тестів чи CLI без HTTP emitter. Один Bootstrap-клас — єдина точка, де видно, які capabilities увімкнені; index.php про це не знає.

<?php declare(strict_types=1);

use Concept\App\Bootstrap\ApplicationStackBootstrap;
use Concept\Core\App;
use League\Container\Container;

$app = App::create();

/** @var Container $container */
$container = $app->getContainer();

(new ApplicationStackBootstrap())->boot($container);

return $app;

Поки що без Early error handling, Foundation (config/path) і Runtime — їх додамо, коли з’явиться реальна потреба, а не «на всяк випадок». Зараз важливо побачити мінімальний шлях до першого response.

3. routes/web.php

HTTP surface тримаємо окремим файлом: URL → handler видно без читання Bootstrap чи контролерів. Коли з’являться middleware — списки теж житимуть тут: поведінка запиту не ховається в Stack. Інакше «що відбувається на /contact» довелося б шукати в кількох місцях.

<?php declare(strict_types=1);

use Concept\App\Controllers\IndexController;
use League\Route\Router;

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

Файл підключає HttpKernel під час boot Stack (див. наступний крок). Змінна $router уже є в scope — її створює kernel перед require route file; вам лишається лише оголосити маршрути.

4. ApplicationStackBootstrap.php

Ми використовуємо addHttp(), щоб ізолювати HTTP-шар: Router, request і response factory з’являються лише коли capability явно увімкнена. Це дозволяє тестувати маршрути й handlers без View, Session чи БД — і розуміти, що саме «дає» цей brick.

Шлях до route file — literal string: на цьому кроці config ще не потрібен і не повинен «підсовувати» шляхи непомітно. Коли пізніше з’явиться PathManager, ви вже знатимете, що він замінює.

<?php declare(strict_types=1);

namespace Concept\App\Bootstrap;

use Concept\Stack\ConceptStack;
use League\Container\Container;

final class ApplicationStackBootstrap
{
    public function boot(Container $container): void
    {
        $rootPath = __DIR__ . '/../../../';
        $stack = ConceptStack::create();

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

        foreach ($stack->providers() as $provider) {
            $container->addServiceProvider($provider);
        }
    }
}

addHttp() збирає Http extension і HttpKernel з базовим resolver chain. providers() повертає готові ServiceProvider — їх додаємо в container явно, а не «десь у фоні». Додаткові опції builder-а (setInterceptors, withFormRequests, …) — у Http brick; у рецепт потрапляють лише коли крок їх потребує.

5. IndexController.php

Handler не знає про Stack і не читає config: він отримує лише те, що оголосив у constructor. Так легше підміняти залежності в тестах і видно контракт методу без «глобальних хелперів». Перша відповідь — сирий текст у body: HTML з’явиться, коли підключимо View.

<?php declare(strict_types=1);

namespace Concept\App\Controllers;

use Concept\Extensions\Http\Response\ResponseFactory;
use Psr\Http\Message\ResponseInterface;

class IndexController
{
    public function __construct(
        private readonly ResponseFactory $responseFactory,
    ) {}

    public function index(): ResponseInterface
    {
        $response = $this->responseFactory->createResponse();
        $response->getBody()->write('Hello, world!');

        return $response;
    }
}

ResponseFactory — з Http extension; container resolve-ить його при dispatch, бо ми вже викликали addHttp().

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

Тег step-01-http містить мінімальний HTTP-стек. Очікуємо plain text Hello, world!, не HTML і не 500.

Запуск

git clone … concept-recipes && cd concept-recipes
git checkout step-01-http
rm -rf storage/cache storage/sessions storage/logs
composer install
cp docker-compose.yml.example docker-compose.yml
docker compose up -d --build

Розробка Core/Stack/Extensions локально (path repos + symlinks): cp docker-compose.dev.yml.example docker-compose.yml, cp composer-dev.json.example composer-dev.json, COMPOSER=composer-dev.json composer install — див. README у репозиторії.

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

Відкрийте http://localhost:8089/ — у вікні має з’явитись один рядок Hello, world! без HTML-розмітки.

Перевірка з CLI

curl -s http://localhost:8089/
# Hello, world!

Ланцюжок запиту

Нижче — шлях одного GET / від Apache/PHP до emitter. Кожен наступний крок додасть вузли в середину (View, middleware, БД), але кінці ланцюжка лишаться тими самими.

public/index.php
  → bootstrap/app.php
    → ApplicationStackBootstrap::boot()
      → ConceptStack::addHttp() → providers()
  → App::handle()
    → Router::dispatch(request)
      → RouteStrategy → IndexController::index()
  → SapiEmitter

Повний опис — у Життєвий цикл запиту.

Крок 2 — HTML-сторінки (addView() + Twig): розмітка виходить з controller, HTTP-шар лишається як був. Огляд усіх кроків — Огляд.