Search by

rjm / blackhole-framework

Rjamessp

Small REST PHP framework base on ADR pattern

Package info

gitlab.com/rjamessp2003/blackhole-framework

Issues

Type:project

pkg:composer/rjm/blackhole-framework

Statistics

Installs: 5

Dependents: 0

Suggesters: 0

Stars: 4

3.0.0 2026-09-07 23:44 UTC

This package is not auto-updated.

Last update: 2026-09-18 13:32:43 UTC


README

Минималистичный PHP-фреймворк для REST API на паттерне ADR (Action–Domain–Responder), заточенный под высокие RPS и тяжёлые CPU-задачи. Работает на RoadRunner — резидентные воркеры, без bootstrap на каждый запрос.

  • PHP >= 8.3, strict types
  • PSR-7 / PSR-11 / PSR-15-совместимое ядро
  • ~20k RPS на проде; особенно хорош на длинных процессорных задачах (конвертация документов и т.п.)

Быстрый старт

composer install
cp .env.example .env
php hawking serve          # RoadRunner на 0.0.0.0:80

ADR

Код приложения — в /app, ядро — в /core.

  1. Action (app/Actions) — принимает Request, возвращает Response, реализует BlackHole\Interfaces\ActionInterface. HTTP-слой.
  2. Domain (app/Domains) — бизнес-логика, DomainInterface.
  3. Responder — ядро само превращает Response в PSR-7 (JSON кодируется один раз).
// app/Actions/Api/V1/Auth/LoginAction.php — реальный пример из демо-каркаса
public function __construct(
    protected UserDomain  $userDomain,   // автовойринг
    protected TokenDomain $tokenDomain,  // автовойринг
    protected Logger      $logger,
) {}

public function __invoke(Request $request): Response
{
    Validator::validateRequestParams($request, ['login', 'password']);
    $data = $request->getJsonBody();
    $user = $this->userDomain->loginByLogin($data['login'], $data['password']);
    $token = $this->tokenDomain->generateToken($user, $ipAddress, $deviceId);
    return new Response(200, ['success' => true, 'token' => $token], ContentType::JSON);
}

Генерация каркасов:

php hawking create:action Api/V1/Bill/Get        # -> app/Actions/Api/V1/Bill/GetAction.php
php hawking create:domain Bill
php hawking create:handler:request AuthToken

DI и автовойринг

Конструкторы Action/Domain/Middleware — любые: зависимости подбирает автовойринг php-di по типам параметров. Обязательных Logger/ContainerInterface больше нет (классы старого стиля продолжают работать — контейнер инжектится по типу).

public function __construct(
    protected BillDomain $billDomain,          // автовойринг
    protected BillRepository $repository,      // автовойринг
    protected Logger $logger,                  // автовойринг
) {}

Контейнер собирает все инстансы один раз при старте воркера (eager-прогрев, ContainerWarmup): дальше get() — прямой возврат из массива, без рефлексии. Stateless-классы живут по одному инстансу на воркер; Request — фасад, его внутренний PSR-7 перевязывается на каждый запрос. Прогрев прогревает и диспетчер роутов — кэш роутера строится на старте, а не на первом запросе.

Роуты (app/routes.php)

Route::get('/health')->action(HealthAction::class)
    ->requestMiddleware(RequestHandler::class)
    ->responseMiddleware(ResponseHandler::class);

Route::group([
    Route::post('/login')->action(LoginAction::class),
], prefix: '/api/v1/auth');

Route::group([
    Route::get('/bill/{userId}/{id:\d+}')->action(GetAction::class)->name('bill.show'),
], prefix: '/api/v1')->requestMiddleware(RequestHandler::class);

Route::url('bill.show', ['userId' => 1, 'id' => 42]); // '/api/v1/bill/1/42'

Middleware привязаны к роуту и приходят из fast-route dispatch: статический /users/admin не получит middleware динамического /users/{id}. Параметры динамики — $request->getAttribute('id'). Response-middleware выполняются при любом статусе ответа — включая short-circuit от request-middleware (auth → 401).

OpenAPI (swagger)

hawking swagger:generate собирает OpenAPI 3 JSON из роутов и PHP-атрибутов Action-классов: пути/методы — из app/routes.php (источник истины роутинга), документация — из атрибутов. Токены динамики ({id:\d+}) автоматически становятся path-параметрами.

#[Tag('auth')]
#[Summary('Логин по email/телефону')]
#[BodyParam('login', type: 'string', example: 'user@example.com')]
#[BodyParam('password', type: 'string')]
#[RespondsWith(200, 'Успешный логин — токен в ответе')]
#[RespondsWith(401, 'Неверный логин или пароль')]
class LoginAction implements ActionInterface { ... }
php hawking swagger:generate                     # -> app/swagger.json
php hawking swagger:generate -o public/openapi.json --title 'Auth API'

Доступные атрибуты: Summary, Description, Tag, PathParam, QueryParam, BodyParam, RespondsWith, Security('BearerAuth') (схема Bearer описана в components.securitySchemes автоматически).

Ошибки

Бизнес-ошибки — исключениями BlackHole\Exceptions\*: их статус и сообщение уходят клиенту как есть. Внутренние ошибки в проде отдают только trace_id (полный trace — в логах), в APP_ENV=dev — полный stack trace.

throw new NotFoundException('Bill not found');       // 404 {"error":"Bill not found"}
throw new ValidationException('Bad inn', ['inn']);   // 422

База данных (database-first)

php hawking db:scaffold bills     # -> app/Database/Entities/Bill.php + Repositories/BillRepository.php
php hawking db:scaffold --all
php hawking db:diff               # дрейф схемы с момента последней генерации

Сгенерированный код — ваш: правьте свободно, повторная генерация не перезапишет. Entity — обычный класс с implements EntityInterface (getId(): string|int). Подключение — переменные DB_* в .env (PostgreSQL/MySQL). MainRepository сам переподключается при потере соединения (server has gone away) и даёт wrap() для транзакций.

Миграции (SQL-драфт из дрейфа)

Источник истины — по-прежнему БД: вы правите схему прямо в ней (или через DDL), миграционный файл — воспроизводимый выхлоп этого дрейфа для CI/staging.

# 1. Правите схему в БД (database-first не нарушен)
php hawking db:migrate:make init                  # первая миграция = baseline всей схемы
php hawking db:migrate                            # применяет неприменённые файлы
# 2. Позже: добавили колонку/таблицу в БД
php hawking db:migrate:make payment-add-comment   # -> app/Database/Migrations/002_payment_add_comment.sql
php hawking db:migrate
  • Новые таблицы/колонки → готовый CREATE TABLE / ADD COLUMN;
  • удаление/смена типа → закомментированные -- TODO: подтвердите (деструктивное не применяется молча);
  • каждая миграция — в транзакции (откат DDL работает на PostgreSQL; MySQL коммитит DDL неявно);
  • применённые версии и снапшот схемы — в таблице bh_migrations; db:migrate:make диффит живую схему с последним применённым снапшотом;
  • индексы/FK не интроспектятся — дописываются в файл руками (фаза 1).

RabbitMQ

Publisher — Queue из контейнера:

$this->container->get(Queue::class)->publish('billing.events', 'bill.paid', ['bill_id' => $id]);

Consumer'ы — реестр app/queue.php, обработка отдельным процессом (не в http-воркере):

QueueRegistry::register('billing.events', 'bill.paid', 'billing.queue', BillPaidConsumer::class);
php hawking queue:work    # долгоживущий consumer (запускать под supervisor/systemd)

queue:work переживает разрыв с брокером (реконнект с повторными декларациями) и завершается корректно по SIGTERM/SIGINT. Сбойное сообщение редоставляется до QUEUE_MAX_RETRIES раз (дефолт 3, счётчик в заголовке x-retry), затем nack(requeue: false) — уходит в DLX, если объявлен у очереди. Consumer'ы резолвятся через DI-контейнер.

Кэш (PSR-16)

BlackHole\Cache — два самостоятельных драйвера за общим интерфейсом Psr\SimpleCache\CacheInterface:

  • FileCache — без расширений; атомарная запись (temp + rename), каталог var/cache/data (переопределяется CACHE_DIR);
  • RedisCache — ext-redis, ленивое соединение с реконнектом (паттерн Queue); clear() чистит только свой REDIS_PREFIX, не FLUSHDB.
$cache = $this->container->get(\Psr\SimpleCache\CacheInterface::class);
$cache->set('bill.42', $bill, 3600);
$bill = $cache->get('bill.42');

Драйвер — CACHE_DRIVER=file|redis в .env (дефолт file).

Docker

Максимально экономный multi-stage образ: composer-стейдж выбрасывается, рантайм — php-cli-alpine без nginx/fpm (HTTP обслуживает RoadRunner), opcache с замороженными таймстампами, ext-redis и pcntl (для SIGTERM в queue:work). RoadRunner-бинарник кладётся в образ при сборке.

docker compose up --build -d          # app:8080 + queue-worker + postgres + rabbitmq
curl http://localhost:8080/health     # {"success":200,...}
docker compose --profile redis up -d  # + redis (опционально)

Сервисы: app (порт 8080), queue-worker (тот же образ, роль — воркер очередей), postgres:16 (порт наружу — для db:scaffold с хоста), rabbitmq (+ management UI на 15672), redis — по профилю. Внутри compose-сети хосты подменяются на имена сервисов (DB_HOST=postgres и т.д.), healthchecks включены. Неиспользуемые сервисы просто уберите из файла.

CLI-помощник hawking

create:action, create:domain, create:handler:request/response, serve, db:scaffold, db:diff, db:migrate:make, db:migrate, swagger:generate, queue:work.

php hawking help                 # обзор всех команд
php hawking help db:migrate:make # детали и примеры конкретной команды

Подробная документация — Docs.html.

Код-стайл ядра

Без трейтов и абстрактных классов: контракты — интерфейсы (ActionInterface, DatabaseInterface, EntityInterface, CacheInterface, ...), реализация — обычные final-классы, переиспользование — композиция (DatabaseConnection, ConnectionLossDetector, ClassNameNormalizer, CacheKey, CacheTtl).

Имя — излучение Хокинга: единственное, что покидает чёрную дыру.

Тесты

composer install && vendor/bin/phpunit

Лицензия

MIT