blackops / skeleton
Feature-first BlackOps application skeleton.
Requires
- php: >=8.5
- blackops/framework: ^1.2
README
Feature-firstのBlackOps Application Skeletonである。Inline GET /welcome、調査用Inline Failure POST /failures、Database Transaction付きPOST /orders、Deferred POST /reports、PostgreSQL 18、FrankenPHP 1、PHP 8.5 CLIを含む。
Distribution Status
このDirectoryはFramework Repository mainのPreview Quickstartであると同時に、Packagist Package blackops/skeletonのSource of Truthである。Release WorkflowがこのDirectoryだけをkubotak-is/blackops-skeletonへSplitし、Frameworkと同じVersionで公開する。公開済みStable 1.1.0にはHeader AuthenticationとPhase 13のDatabase/Transaction Journeyが未収録で、POST /ordersも含まれない。
Local検証ではCommitted QuickstartだけをPackage Rootへ抽出し、SkeletonとFrameworkをsymlink=falseの別々のLocal Repositoryとして通常/--no-scripts Create-projectする。Remote検証は空のComposer HomeからPackagist Packageだけを取得する。
Setup
php bin/setup
docker compose build app http
docker compose run --rm app composer install
pnpm install --frozen-lockfile
docker compose run --rm app php blackops operation:list
docker compose run --rm app php blackops database:status
docker compose run --rm app php blackops database:migrate
docker compose run --rm app php blackops build:compile
docker compose run --rm app php blackops database:seed
docker compose run --rm app php blackops frontend:generate
docker compose run --rm app php blackops frontend:check
pnpm test
docker compose up -d
Composer create-projectはpost-create-project-cmdから同じbin/setupを実行する。--no-scriptsで作成した場合、またはSetupを明示的に再実行する場合はProject Root内外のどのWorking Directoryからでもphp /path/to/my-app/bin/setupを実行できる。Setupは.envがない場合だけ.env.exampleをCopyし、var/build/とvar/log/を準備する。既存.envは変更しない。
Quickstartのbootstrap/app.phpはFrameworkのwithEnvironmentFile()でProcess Environmentを優先してOptional .envを一度だけSnapshotします。Classic/Workerのpublic/index.php/public/worker.phpはBlackOps\Http\SapiRuntimeへApplicationを渡すだけです。Environment、PSR-7、SAPI Emit、UUIDv7のRuntime実装はFrameworkが所有するため、Skeleton Composer MetadataへそのRuntime Packageを重複宣言しません。DBAL/MigrationsなどApplicationが実ImportするPackageだけをApplication Direct Dependencyとして追加します。
composer create-project blackops/skeleton my-app 1.1.0
composer create-project --no-scripts blackops/skeleton my-app 1.1.0 php my-app/bin/setup
Framework Repository内のQuickstartへ直接composer installするだけでは、未公開のblackops/framework:^1.2 candidateをPackagistから解決できず、main PreviewのSourceと一致しない。認証付き/welcome//reportsとPhase 13の/ordersを試す場合は、利用者向けQuickstartのRepository main Preview手順でFramework SourceをLocal Path Repositoryとして組み合わせ、version 1.2.0を明示する。準備後のPreview Directoryでphp bin/setupを実行する。
Install、Build、MigrationはImage startupに含まれない。Default docker compose up はHealthyなPostgreSQLとWorker Mode HTTPだけを起動し、Deferred Worker、Scheduler、Migration、Retention Purgeは起動しない。HTTP Portは .env の HTTP_PORT で変更でき、既定は8080である。
Setupは次手順を表示するだけで、Composer/pnpm Install、Network Access、Docker、Database、Migration、Artifact Build、Frontend生成、TypeScript Test、Worker、Scheduler、Retentionを実行しない。Backendだけで利用する場合はpnpm、frontend:generate、frontend:check、pnpm testを省略できる。build:compileはBackend Artifactと小さいFrontend Contract Artifactを作るが、TypeScript SourceやNode Dependencyを生成しない。
Frontend Operation Objects
Install直後のSkeletonは、Application所有のconfig/frontend.php、package.json、pnpm-lock.yaml、tsconfig*.json、resources/js/application/、tests/Frontend/を含む。resources/js/blackops/はPHP Operationから明示生成するため配布物へ固定せず、.gitignore対象にする。
pnpm install --frozen-lockfile
docker compose run --rm app php blackops build:compile
docker compose run --rm app php blackops frontend:generate
docker compose run --rm app php blackops frontend:check
pnpm test
frontend:generateとfrontend:checkはbuild:compileを暗黙実行しない。frontend:checkはFreshならExit 0、Missing/DriftならExit 1、Config/Artifact/Contract不正ならExit 2を返す。生成したModuleはCallableでもThenableでもなく、通信する.fetch()、一回だけ状態を取得する.status()、明示した期限まで待つ.wait()、送信しない.toRequest()、URLを返す.url()、Readonly Metadataを持つOperation Objectである。
import { GenerateReport, ShowWelcome, operationOptions, } from './resources/js/application/operations'; const options = operationOptions(runtimeToken, 'http://127.0.0.1:8080'); // Input: {} // URL output: "/welcome" const welcomeUrl = ShowWelcome.url(); // Input: reportName、write-onlyのrecipientEmail、呼出単位のCredential // Request output: POST /reports、JSON Body、Content-Type、X-Sample-Token const request = GenerateReport.toRequest( { reportName: 'weekly', recipientEmail }, options, ); // Inputは同じ。OutputはHTTP 202を表すaccepted Result。 const result = await GenerateReport.fetch( { reportName: 'weekly', recipientEmail }, options, ); if (result.ok && result.kind === 'accepted') { result.status; // 202 result.data.operationId; const current = await GenerateReport.status(result.data.operationId, options); const controller = new AbortController(); const terminal = await GenerateReport.wait(result.data.operationId, { ...options, signal: controller.signal, maxWaitMilliseconds: 15_000, }); if (terminal.ok && terminal.kind === 'completed') { terminal.data.outcome.reportName; terminal.data.outcome.location; } } ShowWelcome.type; // 'welcome.show' ShowWelcome.method; // 'GET' ShowWelcome.path; // '/welcome' ShowWelcome.strategy; // 'inline'
recipientEmailの名前と型は送信に必要なWrite-only Input Contractへ含むが、その実値をGenerated Tree、Result、Log Helperへ埋め込まない。X-Sample-TokenもApplicationが呼出単位で注入する。Generated TypeはAuthentication/Authorization、CORS、CSRF、Encryption、Browser Storageを代替しない。
.fetch()は202後に自動Pollingしない。.status()はGET /operations/{operationId}を一回だけ呼び、.wait()だけがServerのRetry-Afterに従って有限に待つ。.wait()には購読可能なAbort Signalと正のmaxWaitMillisecondsが必須である。401、404、410、500、Network Error、不正Responseでは自動Retryせず停止する。
QuickstartはSampleOperationStatusAuthorizerをApplicationServiceProviderから明示Bindingする。同じuser Actorが受け付けたOperationだけを同じActorが参照できる最小Same-origin Exampleであり、Tenant/Role/Resourceを扱うProduction Policyではない。ProductionではApplicationが認証方式とStatus参照Policyを実装する。Operation IDは相関KeyでありSecretではないが、IDを知っているだけでは参照できない。
HTTP
curl -H 'X-Sample-Token: local-example' http://127.0.0.1:8080/welcome curl -X POST -H 'Content-Type: application/json' \ -H 'X-Sample-Token: local-example' \ -d '{"reference":"order-001"}' \ http://127.0.0.1:8080/orders curl -X POST -H 'Content-Type: application/json' \ -H 'X-Sample-Token: local-example' \ -d '{"reportName":"weekly","recipientEmail":"reports@example.com"}' \ http://127.0.0.1:8080/reports curl -i -H 'X-Sample-Token: local-example' \ http://127.0.0.1:8080/operations/<operation-id> curl -X POST -H 'Content-Type: application/json' \ -H 'X-Sample-Token: local-example' \ -d '{"reference":"incident-demo-001","sensitiveNote":"private diagnostic note"}' \ http://127.0.0.1:8080/failures
Failure ResponseのoperationIdはHuman/JSONのどちらでも調べられる。
docker compose run --rm app php blackops operation:inspect <operation-id> docker compose run --rm app php blackops operation:inspect <operation-id> --json
config/logging.phpはApplication/Framework相関Logをvar/log/application.jsonlへJSONLで出力する。Docker-only QuickstartではPostgreSQLをHostへPublishせず、ViewerもCLI ContainerのLoopback限定なので、Hostからphp blackops operation:viewerを実行したりHost Browserで開いたりできない。Docker利用時は上記Human/JSON Inspectを使う。ViewerはConsumer E2Eと同様にViewer/HTTP Clientを同じCLI ContainerとLocal Network Namespaceへ置く場合だけ検証でき、Host Browser公開の手順ではない。
BrowserでViewerを利用するには、Application/PHP CLI/PostgreSQL/Browserが同じLocal Network Namespaceから到達可能なNative Runtimeを準備し、Project Rootでphp blackops operation:viewerを明示実行する。Non-loopback Bindへ緩めずLoopbackを維持し、起動ごとに変わるBootstrap Tokenを保存/共有しない。
OrderはDefault DBAL ConnectionをConstructor InjectionしたRepositoryを使う。CreateOrder::handle()の#[Transactional]が最外Transactionを所有し、Container管理のCreateOrderCommandは#[Transactional]で同じConnectionへNested Required参加する。Order Rowと成功Terminal JournalがCommitした後、#[AfterCommit]のRecordOrderCommit::record()がCommit確認Rowを追加する。
{"reference":"order-001","status":"created"}
After Commitは同期Best-effortであり、Process Crashを越える再送やDeliveryを保証しない。Email、Webhook、Message Publish等をat-least-onceで配送する場合はTransactional Outboxを使い、Relay停止/再開、Retry、Dead Letter再開を明示的に運用する。外部配送のExactly Onceは保証しない。
X-Sample-TokenはAuthentication Middlewareだけが読み、Operation Value、Transport、Journalへ保存しない。SAMPLE_API_TOKENが未設定または空なら、Authenticatorは既知値へFallbackせず構成を失敗させる。
ReportのrecipientEmailは業務上のSensitive値の例である。HTTP内で完了するValidation RejectionのObserved Projectionではvar/log/journal.jsonlへ[masked]として記録する。Valid Deferred ReportのWorker EventはJSONLへ転送せず、Raw ValueとActor IDを含むCanonical PostgreSQL Journalが正本となる。既定Observer Deliveryはbest_effortである。
このHeader Token認証はLocal Development専用のExampleである。ProductionではApplicationがSession、Bearer Token、External IdP等の認証方式とSecret管理へ置き換える。
Headerを省略するとAuthentication MiddlewareはAnonymousとして通過させ、#[Authorize]を持つOperationがOperation ID付き401でRejectする。不正なHeader値はOperation受付前の401となり、Operation IDとJournalを作らない。
curl -i http://127.0.0.1:8080/welcome
curl -i -H 'X-Sample-Token: invalid' http://127.0.0.1:8080/welcome
FrankenPHP Worker Mode
Default HTTPはWorker Modeであり、Process単位でApplication、Environment、Configuration、Compile済みRuntimeを一度だけ構成する。
docker compose up -d http
curl -H 'X-Sample-Token: local-example' http://127.0.0.1:8080/welcome
FRANKENPHP_MAX_REQUESTSはWorker Threadを安全に再起動するRequest上限で、既定は1000である。Frameworkは各Request前にDatabase Connectionをhealth-checkし、Stale Connectionをcloseして一度再接続する。再接続できない場合は500として失敗し、成功Responseとして扱わない。Request終了時はOperation Scopeを検査し、JSONL Observerをflushする。Throwableまたは未完了TransactionのConnectionは次Requestへ持ち越さずcloseする。
FrankenPHPはWorker callback終了後にRequest Superglobalをcleanupするが、$_ENVはRequest間でresetしない。EntrypointはProcess開始時の$_ENVへ毎Request復元する。Application ServiceはRequest Body、Actor、Tenant、PSR-7 Request等のRequest固有Stateをpropertyやstaticへ保持せず、Operation ValueまたはExecution Contextから受け取る。
Classic Modeは明示Fallbackとしてclassic-mode Profileから起動できる。既定Portは8081で、CLASSIC_HTTP_PORTから変更する。
docker compose --profile classic-mode up -d http-classic
curl -H 'X-Sample-Token: local-example' http://127.0.0.1:8081/welcome
Worker and Maintenance
docker compose run --rm app php blackops worker:run --iterations=1 docker compose --profile worker up worker docker compose run --rm app php blackops retention:plan docker compose run --rm app php blackops retention:purge --dry-run docker compose --profile maintenance up scheduler
Sample Report Operationは最初のAttemptでRetryを要求し、次のAttemptで成功する。変更を適用するPurgeは --confirm を明示した場合だけ実行する。
Removing Starter Features
Welcomeは app/Feature/Welcome/、Reportは app/Feature/Report/、Local Failure Exampleはapp/Feature/Diagnostics/を削除するだけでよい。Orderを削除する場合はapp/Feature/Order/とmigrations/Version20260718000000.phpを削除し、ApplicationServiceProviderからOrder用の3 Bindingを外す。config/operations.php のDiscovery Rootへ追加したOperationは次のBuildで検出される。
Operation自身に handle(OperationValue): Outcome、またはAttempt等の実行情報が必要なら handle(OperationValue, ExecutionContext): Outcome を定義するTyped Self-handledが標準形である。ValueとOutcomeはSignatureから推論され、#[Accepts]、#[Returns]、OperationResult::completed()は不要である。予期された業務拒否はFrameworkの OperationRejectedException、一時障害は通常のRetryable Exceptionをthrowする。FrameworkはOperationをContainerへAutowireする。Repository InterfaceやExternal Client等のConstructor Dependencyが必要な場合だけ、ServiceProvider を作り config/app.php の services へ登録する。
Creating an Operation
Framework所有のGeneratorから、Build可能なTyped Self-handled Operation、Value、Outcomeを作成できる。
php blackops make:operation Billing/CreateInvoice --type=billing.invoice.create php blackops build:compile
Generatorはapp/Feature/Billing/CreateInvoice/へ3 Fileを作成する。既存Fileは上書きせず、Route、Deferred設定、Build、Database操作は行わない。生成後のSourceはApplication所有であり、Framework Updateによって書き換えられない。CommandとStubはFramework Packageが所有するため、composer update blackops/framework後の新規生成には更新済み実装が使われ、Projectのblackopsを置き換える必要はない。
Creating a Migration
Application固有のMigrationはFramework所有Generatorで作成する。
php blackops make:migration CreateOrdersTable
最初の実行時にmigrations/が作られ、UTC VersionのApp\Migrations Classが生成される。up()/down()へApplicationのSQLを記述した後、Framework Migrationと同じ明示Commandで確認・適用する。
php blackops database:status php blackops database:migrate --dry-run php blackops database:migrate
Generator自身はDatabase接続、Migration適用、Buildを行わない。Application Migrationがない場合、空のmigrations/は不要である。
Creating and Running a Seeder
Install直後のSkeletonは標準Root app/Infrastructure/Seed/DatabaseSeeder.phpを持つ。追加SeederはFramework所有Generatorで作り、Rootから明示順に実行する。
php blackops make:seeder Catalog/ProductSeeder php blackops database:migrate php blackops build:compile php blackops database:seed
Migration、Build、Seedは互いを暗黙実行しない。SeederはBuild時に検出されるため、Source変更後は必ず再Buildする。Transaction、再実行方針、Conflict処理はApplicationが所有する。