First Operation
Operation生成からHTTP 202受付、Status、Worker、Typed Outcome取得までを完走する。
BlackOps CLIからBilling/CreateInvoiceの骨格を生成し、HTTPで受け付けるDeferred Operationへ仕上げます。
このPageはInstallのStable HTTP 200確認、またはQuickstart and SkeletonのRepository main Preview準備が完了したProject Rootを前提にします。Step 1〜3(Generator、Value、Outcome)に加えて、#[Route]、Deferred実行、WorkerはStable 1.1.0でも利用できます。main Preview限定なのは、Step 4の#[Authorize]とSample Token Header、Step 5のFrontend検証、Step 6・7のStatus Resourceです。Stableではこれらを除いて実行してください。Container CLIを使う場合はdocker compose run --rm appを各Commandの前へ付け、Hostのphp blackopsと混在させません。
起動、Migration、Buildで詰まった場合はTroubleshootingを参照してください。
Release: このTutorialはExperimental Stable
1.1.0のProject Rootblackops、make:operation、宣言的Validation Attributeを使用します。#[Authorize]とSample Token Authentication、Frontend/Status Resourceはmainの未Release Surfaceであり、Repository Quickstart向けです。
1. Generatorから始める
Project Rootで実行します。
docker compose run --rm app php blackops make:operation Billing/CreateInvoice --type=billing.invoice.create
Created: app/Feature/Billing/CreateInvoice/CreateInvoice.php
Created: app/Feature/Billing/CreateInvoice/CreateInvoiceValue.php
Created: app/Feature/Billing/CreateInvoice/CreateInvoiceOutcome.php
Generatorが作るのはBuild可能なOperation、Value、Outcomeの3 Fileです。既存Fileを上書きせず、Route、Execution Strategy、PropertyはApplicationの判断として追加しません。ここから先の3 Fileは利用者が編集する完成形です。
2. ValueへInputとValidationを書く
app/Feature/Billing/CreateInvoice/CreateInvoiceValue.phpを置き換えます。
<?php
declare(strict_types=1);
namespace App\Feature\Billing\CreateInvoice;
use BlackOps\Core\Attribute\Sensitive;
use BlackOps\Core\Attribute\SensitiveMode;
use BlackOps\Core\OperationValue;
use BlackOps\Core\Validation\Attribute\Email;
use BlackOps\Core\Validation\Attribute\Length;
use BlackOps\Core\Validation\Attribute\NotBlank;
use BlackOps\Core\Validation\Attribute\Range;
use SensitiveParameter;
final readonly class CreateInvoiceValue implements OperationValue
{
public function __construct(
#[NotBlank]
#[Length(min: 3, max: 80)]
public string $customerName,
#[Email]
public string $email,
#[Range(min: 1, max: 100)]
public int $quantity,
#[Sensitive(SensitiveMode::Mask)]
#[SensitiveParameter]
#[NotBlank]
public string $billingReference,
) {}
}
PHP TypeはHTTP Bindingの型境界です。NotBlank、Length、Email、RangeはBinding後のValueを検証します。billingReferenceは業務上のSensitive値であり、SensitiveはObserved JournalでMaskし、SensitiveParameterはStack Trace上の引数をRedactします。認証CredentialはこのValueへ追加せずHeader Authenticationへ任せます。
3. Outcomeを書く
app/Feature/Billing/CreateInvoice/CreateInvoiceOutcome.phpを置き換えます。
<?php
declare(strict_types=1);
namespace App\Feature\Billing\CreateInvoice;
use BlackOps\Core\Outcome;
final readonly class CreateInvoiceOutcome implements Outcome
{
public function __construct(
public string $invoiceId,
public string $customerName,
public int $quantity,
) {}
}
Handlerの正常系Return Typeは具象Outcomeだけです。Rejected ResultをReturn Typeへ混ぜず、予期された拒否はFrameworkのExceptionへ委ねます。
4. RouteとDeferred Strategyを書く
app/Feature/Billing/CreateInvoice/CreateInvoice.phpを置き換えます。
<?php
declare(strict_types=1);
namespace App\Feature\Billing\CreateInvoice;
use App\Security\SampleUserAuthorizationPolicy;
use BlackOps\Core\Attribute\Authorize;
use BlackOps\Core\Attribute\Deferred;
use BlackOps\Core\Attribute\OperationType;
use BlackOps\Core\ExecutionContext;
use BlackOps\Core\Operation;
use BlackOps\Http\Attribute\Route;
#[Route(method: 'POST', path: '/invoices')]
#[OperationType('billing.invoice.create')]
#[Deferred]
#[Authorize(SampleUserAuthorizationPolicy::class)]
final readonly class CreateInvoice implements Operation
{
public function handle(CreateInvoiceValue $value, ExecutionContext $context): CreateInvoiceOutcome
{
return new CreateInvoiceOutcome(
invoiceId: $context->operationId()->toString(),
customerName: $value->customerName,
quantity: $value->quantity,
);
}
}
main PreviewではCanonical Attributeの#[Deferred]を使います。Stable 1.1.0で同じDeferred実行を指定する場合は、use BlackOps\Core\Attribute\ExecuteWith;とuse BlackOps\Core\Execution\Deferred;を追加し、#[ExecuteWith(Deferred::class)]へ置き換えてください。Stableへ#[Deferred]を案内しないことが重要です。
Value型とOutcome型はhandle() Signatureから推論されます。Accepts、Returns、Handler用Interfaceは不要です。ExecutionContextが不要なら第二引数ごと省略できます。
5. BuildしてRouteを有効にする
docker compose run --rm app composer dump-autoload
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
docker compose up -d
Build artifacts written.
BuildはOperation Signature、Metadata、Routeを検証し、ManifestとDI Containerを生成します。RuntimeはSource DiscoveryへFallbackしないため、Source変更後は明示的に再Buildします。
6. HTTPで受け付ける
curl -sS -X POST -H 'Content-Type: application/json' \
-H 'X-Sample-Token: local-example' \
-d '{"customerName":"Acme","email":"billing@example.com","quantity":2,"billingReference":"PO-2026-001"}' \
http://127.0.0.1:8080/invoices
{"status":"accepted","operationId":"019f32ab-2be0-7b38-a0a7-1ab2f9687697","acceptedAt":"2026-07-14T01:23:45.678901Z"}
HTTP 202はDurable受付の結果です。HandlerはHTTP Process内でまだ実行されません。operationIdとacceptedAtは実行ごとに変わります。
同じOperation IDをPublic Status Resourceへ渡すと、Worker未起動中のacceptedを確認できます。
OPERATION_ID='019f32ab-2be0-7b38-a0a7-1ab2f9687697'
curl -sS -H 'X-Sample-Token: local-example' \
"http://127.0.0.1:8080/operations/${OPERATION_ID}"
{"schemaVersion":1,"operationId":"019f32ab-2be0-7b38-a0a7-1ab2f9687697","operationType":"billing.invoice.create","state":"accepted"}
不正なEmailを送るとHTTP 422になり、HandlerもDeferred受付も実行されません。
curl -sS -X POST -H 'Content-Type: application/json' \
-H 'X-Sample-Token: local-example' \
-d '{"customerName":"Acme","email":"invalid","quantity":2,"billingReference":"PO-2026-001"}' \
http://127.0.0.1:8080/invoices
{"status":"rejected","operationId":"019f32ab-2be0-7b38-a0a7-1ab2f9687698","category":"validation","code":"validation.failed","violations":[{"field":"email","rule":"email","code":"validation.email"}]}
7. Workerで実行する
docker compose run --rm app php blackops worker:run --iterations=1 --idle-sleep-milliseconds=1
Worker stopped. Processed claims: 1
var/log/journal.jsonlはHTTP ProcessのObserved Projectionです。上で実行した422 RequestはHTTP内でoperation.receivedからoperation.rejectedまで進むため、Validation ResponseのOperation IDで安全なProjectionを確認できます。billingReferenceとActor IDはMaskされ、Header Credentialは保存されません。
VALIDATION_OPERATION_ID='019f32ab-2be0-7b38-a0a7-1ab2f9687698'
grep "$VALIDATION_OPERATION_ID" var/log/journal.jsonl \
| grep -E '"event":"operation.(received|rejected)"'
次はHTTP Observed Encoder Shapeの抜粋です。Operation IDとoccurredAtは実行ごとに変わります。
{"schemaVersion":1,"kind":"journal","event":"operation.received","occurredAt":"2026-07-14T01:24:45.678901Z","sequence":1,"operation":{"id":"019f32ab-2be0-7b38-a0a7-1ab2f9687698","type":"billing.invoice.create","schemaVersion":1,"strategy":"deferred","correlationId":"019f32ab-2be0-7b38-a0a7-1ab2f9687698","causationId":null,"actors":{"origin":{"id":"[masked]","type":"user"},"authorization":{"id":"[masked]","type":"user"},"execution":{"id":"[masked]","type":"user"}}},"attempt":null,"data":{"value":{"customerName":"Acme","email":"invalid","quantity":2,"billingReference":"[masked]"}}}
{"schemaVersion":1,"kind":"journal","event":"operation.rejected","occurredAt":"2026-07-14T01:24:45.679012Z","sequence":2,"operation":{"id":"019f32ab-2be0-7b38-a0a7-1ab2f9687698","type":"billing.invoice.create","schemaVersion":1,"strategy":"deferred","correlationId":"019f32ab-2be0-7b38-a0a7-1ab2f9687698","causationId":null,"actors":{"origin":{"id":"[masked]","type":"user"},"authorization":{"id":"[masked]","type":"user"},"execution":{"id":"[masked]","type":"user"}}},"attempt":null,"data":{"reason":{"category":"validation","code":"validation.failed","violations":[{"field":"email","rule":"email","code":"validation.email"}]}}}
Worker完了後は、同じPublic Status ResourceからTyped Outcomeを取得できます。
curl -sS -H 'X-Sample-Token: local-example' \
"http://127.0.0.1:8080/operations/${OPERATION_ID}"
{"schemaVersion":1,"operationId":"019f32ab-2be0-7b38-a0a7-1ab2f9687697","operationType":"billing.invoice.create","state":"completed","outcome":{"invoiceId":"019f32ab-2be0-7b38-a0a7-1ab2f9687697","customerName":"Acme","quantity":2}}
Frontendでは生成したOperation Objectから同じ経路を型付きで使います。.fetch()は受付だけ、.status()は一回だけ取得し、.wait()はAbort可能な有限待機です。
import { createBlackOpsClient } from './resources/js/blackops';
const blackops = createBlackOpsClient({
baseUrl: 'http://127.0.0.1:8080',
fetch: event.fetch,
headers: { 'X-Sample-Token': 'local-example' },
});
const accepted = await blackops.CreateInvoice.fetch({
customerName: 'Acme',
email: 'billing@example.com',
quantity: 2,
billingReference: 'PO-2026-001',
}, { idempotencyKey: 'invoice-po-2026-001' });
if (accepted.ok && accepted.kind === 'accepted') {
const current = await blackops.CreateInvoice.status(accepted.data.operationId);
const controller = new AbortController();
const terminal = await blackops.CreateInvoice.wait(accepted.data.operationId, {
signal: controller.signal,
maxWaitMilliseconds: 15_000,
});
if (terminal.ok && terminal.kind === 'completed') {
terminal.data.outcome.invoiceId;
terminal.data.outcome.customerName;
terminal.data.outcome.quantity;
}
void current;
}
Canonical PostgreSQL Journalは監査と再現の正本としてActor IDとRaw Valueを保持します。Database暗号化、Access Control、RetentionはApplication/運用の責務です。PHP AdapterからOutcomeだけを読む低Level ContractはPublic OutcomeReaderです。Pending、Terminal、Expiredを区別するときはOutcomeのStatus Queryを主経路にしてください。
作業後はRuntimeを停止します。
docker compose down
Getting Startedを続ける場合はDirectoryでApplicationが所有する配置を確認してください。宣言的Rule、Cross-field Validation、Business Rejectionの詳細はValue and Validationを参照します。