rjm / blackhole-framework
Small REST PHP framework base on ADR pattern
Package info
gitlab.com/rjamessp2003/blackhole-framework
Type:project
pkg:composer/rjm/blackhole-framework
Requires
- php: >=8.3
- ext-json: *
- ext-pdo: *
- composer/xdebug-handler: ^3.0
- filp/whoops: ^2.15
- haydenpierce/class-finder: ^0.5.3
- monolog/monolog: ^3.6
- nikic/fast-route: ^1.3
- nyholm/psr7: ^1.8
- php-amqplib/php-amqplib: ^3.7
- php-di/php-di: ^7.0
- psr/http-server-middleware: ^1.0
- psr/simple-cache: ^3.0
- spiral/roadrunner-cli: ^2.6
- spiral/roadrunner-http: ^3.5
- symfony/console: ^7.4
- vlucas/phpdotenv: ^5.6
Requires (Dev)
- phpunit/phpunit: ^11.0
Suggests
- ext-redis: For the BlackHole\Cache\RedisCache driver (CACHE_DRIVER=redis); fallback driver FileCache needs no extensions
Provides
None
Conflicts
None
Replaces
None
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.
- Action (
app/Actions) — принимаетRequest, возвращаетResponse, реализуетBlackHole\Interfaces\ActionInterface. HTTP-слой. - Domain (
app/Domains) — бизнес-логика,DomainInterface. - 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