diff --git a/docs/plans/2026-07-12-framework-coroutine-state-lifecycle-audit-ledger.md b/docs/plans/2026-07-12-framework-coroutine-state-lifecycle-audit-ledger.md
index da375f9d1..5d1af15cd 100644
--- a/docs/plans/2026-07-12-framework-coroutine-state-lifecycle-audit-ledger.md
+++ b/docs/plans/2026-07-12-framework-coroutine-state-lifecycle-audit-ledger.md
@@ -830,3 +830,88 @@ Append package entries in checklist order. Keep each entry compact but complete
- **Validation and review:** Every changed test file and all affected Filesystem, Cache, HTTP, Routing, Foundation, HTTP Server, contract, facade, environment, session, and streaming groups pass. PHP CS Fixer changed none of 5,574 files; both PHPStan configurations pass; the complete components suite passes with 23,282 tests, 66,332 assertions, and 1,600 expected skips; Testbench passes with 346 tests, 1,029 assertions, and 3 expected skips; dogfood passes with 4 tests and 7 assertions; `git diff --check` and package-checklist parity are clean. Fresh full-diff caller/callee, resource-lifecycle, API, performance, stale-code, and overengineering review is complete, and independent code review signed off on the final MIME, SSE, documentation, test, and ledger corrections.
- **Laravel-facing result:** Current Laravel filesystem APIs, configuration structure, and conventional extension shapes remain compatible. The owner approved the safer native-failure behavior, the Swoole-specific lazy remote-streaming default, the bounded native lock-detection tail, and protocol-correct multiline SSE framing where current Laravel emits invalid subsequent data lines; `stream_reads=false` preserves the documented eager transport option. No public API was removed or renamed, and the added `assertEmpty()` surface restores current Laravel parity.
- **Assessment:** The result fixes verified native-boundary, ownership, streaming, disconnect, and cleanup defects at their lowest owners while bounding remote-read and response-stream memory. Ordinary non-streaming requests and ordinary cloud reads gain no new runtime work; single-line SSE events add one native scan without normalized-data allocation, while multiline events incur only the bounded native string work required for correct framing. The design adds no registry, replacement lock, retry loop, timeout policy, context state, custom cURL bridge, resource state machine, compatibility shim, or speculative Image surface; every accepted mechanism has a demonstrated consumer and the completed work is free of overengineering.
+
+### Make Pipeline builders transient and restore current upstream fidelity
+
+- **Architecture and inspected risk surfaces:** Pipeline is a Laravel-derived mutable per-operation builder plus a worker-lifetime named Hub registry. The audit covered every Pipeline source, unit, integration, contract, facade, provider, and documentation surface; every repository consumer and subclass; current Laravel 13.x source, tests, package metadata, documentation, and originating framework/documentation pull requests; the container's auto-singleton behavior; Macroable cleanup; and the completed `support-02` enum-identifier boundary.
+
+| ID | Category | Severity | Confidence | Failure and owning boundary | Final decision |
+|---|---|---|---|---|---|
+| `pipeline-01` | Defect | Major | High | The canonical facade key is transient, but the unbound concrete `Pipeline::class` falls into the worker-lifetime auto-singleton cache, allowing independently resolved mutable builders to overwrite another coroutine's execution-time state | Bind the concrete and facade key transiently through one factory; document why the concrete binding must bypass auto-singletoning and prove finalizer/passable isolation with deterministic sibling coroutines |
+| `pipeline-02` | Defect | Minor | High | Undefined Hub pipelines fall through an undefined-key warning and incidental native exception rather than the current Laravel `InvalidArgumentException` contract | Port Laravel's current guard and dedicated Hub regression after Hypervel's exact null/empty default selection |
+| `pipeline-03` | Package metadata defect | Minor | High | Database is an undeclared direct dependency of the public `withinTransaction()` capability even though Support currently installs it transitively | Add a direct sorted `hypervel/database` requirement; do not copy Laravel's factually inapplicable optional suggestion |
+| `pipeline-04` | Test fidelity defect | Minor | High | The transaction connection regression discards `Event::dispatched()` and compares a `Connection` object with a string, so every connection-selection case passes vacuously | Assert the dispatch and compare `ConnectionEvent::$connectionName`; retain enum, string, null, and integer-backed delegation coverage |
+| `pipeline-05` | Documentation defect | Minor | High | The package README omits its Laravel provenance | Add the current Laravel Pipeline source reference without unnecessary package prose |
+
+- **Ownership and performance boundary:** Reuse one boot-created factory closure under `Pipeline::class` and `pipeline`. Existing facade resolution performs the same factory call and gains no work; direct concrete resolution deliberately changes from one unsafe worker instance to one fresh allocation per resolution, restoring Laravel's builder lifetime. The owner approved that required allocation tradeoff. The Hub guard adds one predictable `isset` check per named dispatch. No per-pipe context lookup, lock, clone, registry, retry, yield, or new retained request state is added.
+- **Cross-package and support revalidation:** `support-02` remains correct without source changes: Hub preserves named pipeline `"0"`, while `withinTransaction()` carries enums unchanged to Database's owning string boundary, including integer-backed zero. Bus's shared Pipeline remains safe because its non-yielding `send()->through()->then()` prefix snapshots the passable and pipes before execution and Bus never mutates the method, container, finalizer, or transaction fields read during yielding execution. This invariant is recorded for later Bus revalidation; no Bus source change or hot-path clone is justified.
+- **Important rejected concerns:** Do not add CoroutineContext state, locks, immutable fluent copies, scoped binding, `SelfBuilding`, a generic transient marker, subclass registry, per-dispatch Bus clone, Pipeline-contract binding, Hub concrete/contract identity machinery, native-finally semantic changes, or closure/call-user-function micro-optimizations. The base concrete binding cannot enumerate userland subclasses; custom subclasses remain governed by the general container lifetime rules rather than package-owned machinery.
+- **Upstream and regression strategy:** Laravel framework pull requests `#60802`, `#56377`, `#56447`, `#56550`, and `#56567` and documentation pull requests `#10677`, `#10689`, and `#10691` supply discovery history; current local Laravel 13.x source, tests, metadata, and documentation supply the porting reference. Merge the zero-name regression into the new current Hub test, reproduce concrete-resolution finalizer contamination with bounded sibling-coroutine handshakes, make the inherited transaction assertion meaningful, and apply the repository-required `: void` only to Pipeline package test methods. Record the current Laravel object-versus-name test defect for owner coordination rather than opening an external change.
+- **Implemented changes:** Pipeline's provider now reuses one stateless factory for transient concrete and facade-key bindings, with the concrete auto-singleton rationale recorded at the binding. Hub now fails undefined names with Laravel's current exception after preserving Hypervel's exact null/empty fallback. The package declares Database directly and records its Laravel provenance. Current upstream Hub coverage, the existing zero-name regression, deterministic concrete-resolution coroutine isolation, meaningful transaction connection assertions, strict result comparisons, and Pipeline test-method return types cover the corrected behavior without adding production test seams.
+- **Laravel-facing result:** Public APIs, configuration, and conventional call shapes remain unchanged. Concrete container resolution becomes fresh like Laravel, undefined Hub names gain Laravel's current exception, and package metadata declares Hypervel's actual direct dependency rather than copying Laravel's different split-package architecture.
+- **Validation and review:** The Hub and concrete-resolution regressions fail against the old source for the intended undefined-name and cross-coroutine finalizer-contamination reasons. All 32 Pipeline tests with 77 assertions and all five transaction tests with 12 assertions pass. Package Composer metadata and `git diff --check` are clean. The final `composer fix` run changed none of 5,576 files; both PHPStan configurations pass; the complete components suite passes with 23,286 tests, 66,342 assertions, and 1,600 expected skips; Testbench passes with 346 tests, 1,029 assertions, and 3 expected skips; and dogfood passes with four tests and seven assertions. Fresh full-diff caller/callee, lifecycle, API, metadata, performance, stale-code, and overengineering review is complete, and independent code review signed off without findings.
+- **Assessment:** The result fixes each verified defect at its owning boundary and restores current Laravel behavior without a compatibility layer or broader lifecycle mechanism. Existing facade dispatch gains no work; direct concrete resolution pays only the approved fresh-builder allocation required for correctness, and Hub adds one predictable `isset` guard per named dispatch. No per-pipe context lookup, lock, clone, registry, retry, yield, retained request state, or speculative extension surface was added.
+
+### Make Bus dispatch, batches, and unique payloads lifecycle-safe
+
+- **Architecture and inspected risk surfaces:** Bus is a Laravel-derived worker-singleton dispatcher over mutable pending-dispatch builders, queue connections, database-backed batch metadata, fakes, and optional lifecycle events. The audit covered every Bus source and test file; Foundation pending dispatch and application wiring; Queue payload creation, transaction deferral, missing-model restoration, and debounce handling; Support fakes; Horizon batch reads; current Laravel 13.x source, tests, documentation, and originating pull requests; and the completed `queue-12`, `support-02`, and Pipeline invariants.
+
+| ID | Category | Severity | Confidence | Failure and owning boundary | Final decision |
+|---|---|---|---|---|---|
+| `bus-01` | Defect | Major | High | The direct `QueueingDispatcher` alias bypasses a `Bus::fake()` swap and resolves the real dispatcher | Chain the queueing contract alias through the base dispatcher contract and prove both resolve the fake |
+| `bus-02` | Defect | Minor | High | Bus lacks current Laravel bulk dispatch, fake support, facade metadata, tests, and documentation; upstream's delimiter-concatenated grouping key silently merges supported colon-bearing connection and queue routes | Port current bulk and immediate-dispatch behavior with a nested connection/queue route map; require `bulk()` on Hypervel's `QueueingDispatcher` because every conforming queue dispatcher must provide the facade capability |
+| `bus-03` | Defect | Minor | High | `PreparesForDispatch` rejects valid void implementations, and pending dispatch cannot disable a previously selected after-response mode | Restore Laravel's `bool|void` contract and `afterResponse(bool)` toggle with current integration and Conditionable coverage |
+| `bus-04` | Defect | Minor | High | Batch started/canceled events, cancellation exceptions, first-job detection, explicit chain routing, and finished-state batching differ from current Laravel | Port the complete current lifecycle and guard optional observational events with `hasListeners()` |
+| `bus-05` | Defect | Minor | High | Truthiness drops public/custom batch ID `"0"` in `Batchable`, its fake, and paginated repository reads | Use the exact null/empty sentinels at each existing boundary without a normalizer |
+| `bus-06` | Defect | Major | High | Concurrent batch deletion or unfinished pruning makes atomic count updates read absent fields, while a later callback refresh can pass null to a typed Batch callback | Return nullable updated counts from the locked repository boundary and stop completion/failure/callback processing when the batch no longer exists |
+| `bus-07` | Defect | Major | High | The worker-singleton database batch repository exposes an unused connection-name mutator that can redirect sibling coroutines | Keep per-call pooled resolution, rename the accessor to `getConnection()`, remove `setConnection()`, and record the intentional Laravel difference |
+| `bus-08` | Defect | Minor | High | Batching table call-site defaults duplicate framework config and Horizon reads the table through an untyped helper result | Remove dead defaults, use typed getters, and retain the nullable batching database key |
+| `bus-09` | Defect | Minor | High | Debounce ownership performs redundant cache reads and concurrent first writers can overwrite the original max-wait anchor | Port current single-read/owner behavior and use atomic `Cache::add()` for first-anchor creation |
+| `bus-10` | Defect | Minor | High | Superseded jobs construct and dispatch `JobDebounced` even with no listener | Guard the optional event at the narrowed dispatcher boundary and retain EventFake visibility |
+| `bus-11` | Defect | Minor | High | Pending batch trait detection diverges from current Laravel's keyed class-trait lookup | Restore the current `isset(class_uses_recursive(...)[Batchable::class])` source shape |
+| `bus-12` | Defect | Minor | High | BusFake misses sync/after-response work in its empty assertion, rejects array batch assertions, returns values the real fake contract does not, ignores batch serialization, and its batch fakes drift from current behavior | Port current fake behavior and comprehensive upstream coverage while retaining stricter Hypervel regressions; serialize batch jobs at the recording boundary; and correct the upstream double-dispatch, serialization-expectation, and wrong-assertion test defects |
+| `bus-13` | Typing defect | Minor | High | Batch repository transaction callbacks lack the generic return contract carried by current Laravel | Port the current generic PHPDocs across the contract, database implementation, and fake |
+| `bus-14` | Documentation defect | Minor | High | Bus provenance and the intentional unsupported DynamoDB repository omission are not recorded | Add the current Laravel source reference plus the required README and natural provider omission comment without an empty test artifact |
+| `bus-15` | Documentation defect | Minor | High | Queue documentation lacks current bulk and prepare-for-dispatch guidance, omits one existing TOC entry, and leaves accepted public batch/dispatch testing surfaces undiscoverable | Port the current task-first sections, complete the local TOC, and document conditional after-response dispatch, direct batch assertions, empty-dispatch assertions, and observable batch events concisely |
+| `bus-16` | Defect | Major | High | PendingDispatch's request-wide unique metadata bracket can remain stranded when preparation, lock acquisition, serialization, or dispatch throws | Remove the bracket rather than patch each exceptional exit |
+| `bus-17` | Defect | Major | High | After-response and Sync/Background/Deferred after-commit paths can serialize after unique metadata is removed, so missing-model restoration cannot release the acquired lock; coroutine-global metadata also lacks job identity | Register acquired-lock metadata against the exact arbitrary job in a lazy weak sidecar and consume it only at object-payload creation |
+| `bus-18` | Package metadata defect | Minor | High | Foundation registers Log providers and Queue imports Log context classes without either package declaring `hypervel/log` | Add the sorted direct dependency to both package manifests |
+| `bus-19` | Test lifecycle defect | Minor | High | `BusBatchTest` stores callback state in worker-global `$_SERVER`, clears only five keys after throwable parent teardown, and can contaminate later tests with counts, batches, exceptions, and failure metadata | Unset the complete 18-key set owned by the test before parent teardown |
+
+- **Unique payload ownership:** `Bus\UniqueJobPayloadContext` owns a lazily initialized worker-local `WeakMap` keyed by the exact job object. `PendingDispatch` registers only after successful unique-lock acquisition. `Queue::createObjectPayload()` consumes the exact entry before payload hooks and scopes the existing Laravel interoperability keys only around Context dehydration. Weak keys cover dispatch failure before serialization; deferred callbacks retain the job until serialization; a pure `flushState()` participates in authoritative test cleanup. This replaces `InteractsWithUniqueJobs` and fixes exception-stranded context, after-response loss, Sync after-commit loss, and delayed Background/Deferred after-commit loss at their shared boundary.
+- **Approved API and performance boundary:** The owner approved the additive `QueueingDispatcher::bulk()` requirement, nullable batch-count contract, removal of Laravel's unsafe `setConnection()` surface, atomic first-anchor `Cache::add()` divergence, and the exact-object sidecar. Ordinary object payloads gain one static-null branch; a WeakMap lookup occurs only while unique registrations are outstanding, and Context/closure work only for the registered unique payload. Batch completion gains one null check beside an existing locked database transaction. Debounce owner lookup removes cache I/O; the atomic first write changes only the cold anchor path.
+- **Important rejected concerns:** Do not add queue-resolver guards, batch retry/tombstone/state-machine machinery, generalized event swallowing, a mutable connection override in CoroutineContext, a debounce lock/retry abstraction, a per-dispatch Pipeline clone, eager unique payload serialization, job dynamic properties, wrapper jobs, public deferred callback parameters, defer-order choreography, or distributed rebracketing at every delayed owner. A custom payload hook that synchronously dispatches a distinct direct unique job inside the outer Context-dehydration scope can still inherit the outer hidden keys; closing that extremely narrow conjunction would require a public payload-hook change or direct coupling to Log's serialized payload structure and is rejected as overengineering.
+- **Cross-package implications and revalidation:** Bus owns dispatcher, batch, debounce, fake, and exact unique metadata registration. Contracts owns truthful dispatcher, preparation, and batch-repository surfaces; Foundation owns pending dispatch, service aliases, and its Log dependency; Queue owns exact payload consumption, missing-model release, debounce handling, and its direct Log-context dependency; Support owns fakes and facade metadata; Testing owns static reset; and Horizon consumes typed batch configuration. The completed `queue-12` and `support-02` boundaries and Pipeline's shared-dispatch invariant were re-traced and remain unchanged. Focused and full-suite coverage revalidates every changed consumer.
+- **Upstream and documentation:** Laravel framework pull requests `#58659`, `#59118`, `#59163`, `#59233`, `#59378`, `#59457`, `#59458`, `#59879`, `#60047`, `#60297`, `#60500`, `#60511`, `#60513`, `#60559`, `#60575`, and `#60745`, plus the bulk and preparation documentation commits, supplied discovery history; current local Laravel 13.x source, tests, metadata, and docs supplied the implementation reference. Hypervel corrects the upstream bulk grouping key because unrestricted connection and queue names can contain the same delimiter and silently merge distinct routes. The Queue guide now documents bulk and preparation, conditional after-response dispatch, observable batch events, and the accepted fake assertions. It also warns that bulk dispatch bypasses preparation, unique-job, and debounce dispatch lifecycles. Bus records its Laravel provenance and the deliberate DynamoDB and mutable-connection omissions without exposing internal sidecar or repository mechanics as application guidance.
+- **Implementation:** The queueing contract now resolves through the fakeable dispatcher contract and supports collision-free bulk grouping through a nested connection/queue route map; preparation accepts bool or void, and after-response dispatch is reversible. Batch lifecycle events, cancellation exceptions, route preservation, finished-state checks, zero identifiers, nullable concurrent-deletion updates, per-operation pooled connections, generic transaction typing, and typed configuration now agree across contracts, implementations, fakes, Horizon, and docs. Debounce processing uses one owner read, an atomic first anchor, and listener-aware optional events. A lazy exact-object WeakMap sidecar replaces the request-wide unique-context bracket and scopes interoperability metadata only around the payload that consumes it. Bus fakes match current behavior, serialize all recorded batch jobs including later additions, and retain stricter Hypervel assertions. The three verified upstream test defects are corrected, and Bus batch callbacks clear all 18 worker-global keys before throwable parent teardown.
+- **Regression tests:** Focused coverage proves facade replacement, bulk routing including colon-bearing route separation, bool/void preparation, after-response toggling, every batch terminal and deletion-race path, zero IDs and cursors, explicit chain routing, debounce atomicity and one-read ownership, optional-event behavior with EventFake, current fake semantics and recorded batch serialization, exact-object consume-once metadata, weak-key release, after-response and every after-commit payload builder, exceptional context restoration, persistent payload interoperability, exhaustive callback-state teardown, and the corrected public assertion methods.
+- **Performance and complexity:** Ordinary dispatch and batch database I/O add only the approved exact checks at their existing boundaries. Bulk dispatch replaces delimiter concatenation with one bounded array level per distinct connection and performs no per-job key encoding or explicit hashing. Every object payload performs one static-null branch; a WeakMap lookup occurs only while unique registrations exist, and Context/closure work occurs only for the registered unique payload. Batch completion adds one null check beside its existing locked transaction. Debounce execution removes one cache read; the atomic add affects only first-anchor creation. Optional event guards avoid construction and dispatch without listeners. No lock, retry, tombstone, state machine, eager serialization, wrapper job, dynamic property, per-dispatch Pipeline clone, context registry, or compatibility layer was added.
+- **Laravel-facing result:** Public call shapes and configuration remain compatible while current bulk, preparation, after-response, batch lifecycle, fake, and documentation surfaces are restored. The additive `QueueingDispatcher::bulk()` and nullable batch-count contracts make implemented behavior truthful. The owner-approved omissions are Laravel's DynamoDB repository and mutable `DatabaseBatchRepository::setConnection()`; both are documented with their concrete Hypervel reasons. Hypervel additionally fixes current upstream batch-fake serialization and test defects rather than preserving them.
+- **Validation and review:** The focused cross-package suite passes with 286 tests, 706 assertions, and 37 expected skips; focused Bus and Testbench follow-up coverage passes with 140 tests and 376 assertions. PHP CS Fixer changed none of 5,580 files; both PHPStan configurations pass; the complete components suite passes with 23,411 tests, 66,744 assertions, and 1,603 expected skips; Testbench passes with 347 tests, 1,031 assertions, and 3 expected skips; and dogfood passes with four tests and seven assertions. `git diff --check`, broad stale-reference and implementer scans, and a fresh full-diff caller/callee, lifecycle, API, documentation, hot-path, and overengineering review are complete. The original review added the bulk lifecycle warning, and the follow-up review independently reproduced the route collision; both signed off with no remaining finding.
+- **Assessment:** Every verified dispatch, batch, debounce, fake, unique-payload, metadata, documentation, and test-isolation defect is fixed at its lowest owner. The request-wide unique context and unsafe mutable connection surface are removed completely, cross-package assumptions are revalidated, and the ordinary hot paths retain only the narrow approved correctness checks. The result contains no workaround, speculative mechanism, stale compatibility code, or unresolved finding.
+
+### Harden Core lifecycle callbacks and stdout logging
+
+- **Architecture and inspected risk surfaces:** Core translates positional native Swoole callbacks into Hypervel lifecycle events and provides the low-level stdout logger inherited by worker processes. The audit covered every Core source and test file; the Server callback-registration and configuration boundaries; Foundation configuration bootstrap, stable repository reload, and base logger binding; all repository event and logger consumers; Swoole 6.2.2 callback dispatch source; Symfony Console output handling; PSR-3 contracts; and the historical Hyperf framework source.
+
+| ID | Category | Severity | Confidence | Failure and owning boundary | Final decision |
+|---|---|---|---|---|---|
+| `core-01` | Defect | Major | High | The stdout logger snapshots configuration in the master process and replacement workers retain stale levels and format after Foundation reloads the stable Config repository | Add a default-concrete boot-only configuration reload after successful `BeforeWorkerStart` listeners and before startup logging/readiness; validate into locals, build the enabled-level map once, and publish both settings together |
+| `core-02` | Defect | Major | High | PSR context interpolation can emit warnings or throw for arrays, resources, throwing stringables, percent-bearing tags, and Symfony markup interpreted as console formatting | Use PSR `LoggerTrait`, safe `strtr` interpolation, a dedicated component tag, direct line construction, and conditional escaping of dynamic console values |
+| `core-03` | Defect | Major | High | JSON logging can throw for user serializers or emit undecodable output when Symfony Console interprets markup in encoded data | Encode with partial-output and invalid-UTF-8 flags, write raw output, catch `Throwable`, retry once without context, and retain valid JSON `null` as the unreachable final fallback |
+| `core-04` | Defect | Minor | High | Invalid stdout formats and level entries are accepted during boot, while unknown runtime levels are silently ignored contrary to PSR-3 | Validate supported formats and string level entries during reload, preserve configured custom string levels, and throw `Psr\Log\InvalidArgumentException` for unknown or non-string runtime levels |
+| `core-05` | Defect | Major | High | Task callbacks select the native object signature only for coroutine tasks, although Swoole also uses it for `task_object` and legacy `task_use_object` with presence-sensitive precedence | Declare the canonical disabled coroutine-task default, derive object mode once from all dedicated task settings, and route completion through the native Task or Server boundary that owns the signature |
+| `core-06` | Defect | Major | High | Truthy `event_object` changes numerous native Swoole callback signatures that Core's positional lifecycle bridge cannot consume | Reject the setting at the global and per-port settings mutation boundaries and direct users to Hypervel lifecycle events instead of adding a dual callback adapter |
+| `core-07` | Defect | Minor | High | `OnReceive::$data` is wider than its sole string caller, while three public Hyperf-era classes are empty or unconsumed and imply unsupported alternatives | Narrow receive data to string and remove `ServerStartCallback`, `NotImplementedException`, and `ConsoleLogger` completely |
+| `core-08` | Package metadata defect | Minor | High | Core omits its direct Swoole requirement and provenance while declaring an unused Coroutine dependency and carrying stale logger descriptions | Require `ext-swoole`, remove the false dependency, record Hyperf framework provenance, and correct concise configuration and logging guidance |
+
+- **Approved owner gates:** The owner approved rejecting truthy global/per-port `event_object`, declaring `task_enable_coroutine=false`, enforcing the PSR unknown-level exception, deleting the three unused public classes, and the measured approximately 58-nanosecond dynamic-value escaping guard on enabled line-format logs.
+- **Important rejected concerns:** Do not add per-log Config reads, PID checks, configuration observers, an optional refresh interface, logger recreation, a recursive JSON normalizer, Monolog dependency, stdout mutex, dual `event_object` adapter, worker-start `finally`, broad listener guards, request/coroutine logger state, event renaming, or exhaustive tests for trivial event DTOs. The supported stdout path performs one write per line and no demonstrated interleaving failure justifies worker-wide serialization.
+- **Implementation boundary:** Reload only the default concrete logger after successful worker boot mutation, leaving custom implementations and the marker contract unchanged. Preserve lifecycle event dispatch as the callback bridge's requested side effect. Model both dedicated native task signatures without expanding support to `event_object`. Reject truthy `event_object` in `ServerConfig` and `Port` setters so construction and later supported boot-time mutation cannot publish an incompatible callback mode; do not add a validator or startup rescan. Validate logger configuration at cold boot boundaries and retain only small precomputed worker state.
+- **Regression strategy:** Extend stdout coverage for PSR interpolation, console escaping, configuration validation, custom and invalid levels, raw decodable JSON, recursive/resource/invalid-UTF-8 context, and throwing serializers. Add worker-start reload ordering, every dedicated task-object switch and precedence case, global/per-port `event_object` rejection, and the explicit Foundation default.
+- **Performance and complexity:** Configuration validation and reload occur once per worker, task signature selection occurs once per callback construction, and `event_object` rejection occurs before startup. Enabled-level map lookup is faster than the current list scan, JSON bypasses Symfony formatting, and no request/coroutine path gains a container lookup, lock, retry, yield, registry, or retained request state. The only accepted added work is the small conditional escape check for enabled line output required to preserve literal user values.
+- **Laravel-facing result:** Core is Hyperf-derived low-level Swoole infrastructure rather than a Laravel-ported package. Laravel API parity is not applicable; PSR-3 behavior becomes conformant, and the owner approved the narrower Hypervel-specific raw Swoole configuration surface.
+- **Implementation:** The default stdout logger now reloads validated worker configuration after Foundation's stable-repository rebuild, uses a precomputed level map, performs safe PSR interpolation and conditional line escaping, and emits resilient raw JSON with informative top-level object and resource markers. Task callbacks select Swoole's exact legacy or native-object signature and finish boundary from all dedicated settings. Global and per-port settings reject incompatible truthy `event_object` values at their complete mutation boundaries while allowing explicit false. Receive data is narrowed to string; the three dead public classes and false Coroutine dependency are removed; and package provenance, the direct Swoole requirement, shipped defaults, and user guidance are current.
+- **Regression tests:** Stdout coverage exercises safe scalar, array, resource, object, date, invalid-UTF-8, recursive, and throwing-serializer context; literal Symfony markup and percent-bearing tags; raw JSON; custom, disabled, invalid, and non-string levels; invalid formats; and failed-reload atomicity. Worker-start coverage proves reload ordering before startup output and readiness while custom loggers remain untouched. Task coverage proves legacy construction and completion, every native-object setting, and legacy-alias precedence; a live-server test solely for the final native `Task::finish()` call was rejected as disproportionate because the final extension class has no honest unit seam and Swoole source defines that ownership boundary. Server coverage proves construction-time and later global/per-port `event_object` rejection, port-specific diagnostics, explicit-false acceptance, and the shipped disabled coroutine-task default.
+- **Validation and review:** The focused affected group passes with 81 tests and 229 assertions. PHP CS Fixer changes none of 5,580 files; both PHPStan configurations pass; the complete components suite passes with 23,410 tests, 66,742 assertions, and 1,603 expected skips; Testbench passes with 347 tests, 1,031 assertions, and 3 expected skips; and dogfood passes with four tests and seven assertions. Root and split Composer validation, PHP syntax checks, `git diff --check`, broad stale-reference and settings-writer scans, a fresh full-diff caller/callee, lifecycle, API, documentation, hot-path, and overengineering review, and independent code review are complete. The final review improved top-level resource JSON normalization and signed off with no remaining issue.
+- **Assessment:** All eight verified Core findings are fixed at their lowest owning boundary without a compatibility shim, dual callback adapter, per-log configuration lookup, synchronization layer, recursive normalizer, or speculative test seam. Cold boot and callback-construction paths own the new validation and selection work; ordinary logging uses a faster level lookup and only the approved small escaping guard. The result contains no stale surface, retained request state, hot-path regression, workaround, or unresolved finding.
diff --git a/docs/plans/2026-07-12-framework-coroutine-state-lifecycle-audit.md b/docs/plans/2026-07-12-framework-coroutine-state-lifecycle-audit.md
index 0c5b40e4f..41117dba1 100644
--- a/docs/plans/2026-07-12-framework-coroutine-state-lifecycle-audit.md
+++ b/docs/plans/2026-07-12-framework-coroutine-state-lifecycle-audit.md
@@ -990,9 +990,9 @@ An exceptionally large shared work unit may receive its own linked detail plan w
This compact index routes the completed-work history that must be consulted with the full plan after compaction. Detailed history remains in the [companion ledger](2026-07-12-framework-coroutine-state-lifecycle-audit-ledger.md).
-- **Active package or work unit:** `pipeline`
-- **Ledger entries required for the active work:** `Normalize framework enum identifiers at string boundaries` (`support-02`).
-- **Pending revalidation carried into the active work:** Revalidate `support-02` during the full Pipeline audit.
+- **Active package or work unit:** `foundation`
+- **Ledger entries required for the active work:** `Harden framework contracts and request-scoped state` (`view-01`); `Restore Conditionable proxy truthiness` (`testbench-01`); `Consolidate reflection metadata and correct callable inference` (`reflection-01`, `reflection-02`); `Preserve configuration identity across worker reloads` (`config-01`, `config-02`); `Correct explicit coroutine context targeting` (`context-01`, `context-04`); `Make coroutine creation and copied context failure-safe` (`coroutine-06`, `foundation-02`); `Make process concurrency transport lossless and reconstruct failures safely` (`concurrency-01`, `concurrency-03`); `Correct AOP proxy generation and publication` (`di-02`); `Harden filesystem I/O, streaming, and response teardown` (`http-02`, `filesystem-07`, `foundation-04`); `Correct event dispatch, queued-consumer isolation, and queue interoperability` (`events-01`, `events-04`, `events-06`, `foundation-01`); `Normalize framework enum identifiers at string boundaries` (`support-02`); `Harden encryption rotation, key publication, and global lifecycle state` (`encryption-03`); `Make custom server processes failure-safe` (`server-process-10`); `Make Bus dispatch, batches, and unique payloads lifecycle-safe` (`bus-03`, `bus-17`, `bus-18`); and `Harden Core lifecycle callbacks and stdout logging` (`core-01`, `core-05`).
+- **Pending revalidation carried into the active work:** Revalidate `view-01`, `testbench-01`, `reflection-01`, `reflection-02`, `config-01`, `config-02`, `context-01`, `context-04`, `coroutine-06`, `foundation-02`, `concurrency-01`, `concurrency-03`, `di-02`, `http-02`, `filesystem-07`, `foundation-04`, `events-01`, `events-04`, `events-06`, `foundation-01`, `support-02`, `encryption-03`, `server-process-10`, `bus-03`, `bus-17`, `bus-18`, `core-01`, and `core-05` during the full Foundation audit.
Update these three lines when a package starts, completes, or gains a cross-package dependency. Name exact work-unit headings or shared finding IDs from the companion ledger; never use “see recent entries” or require a full-ledger reread.
@@ -1050,14 +1050,21 @@ Add one row only for a shared finding or changed lower-level assumption that ano
| `events-05` | `events`, `broadcasting` | later full `broadcasting` audit | `Correct event dispatch, queued-consumer isolation, and queue interoperability`; finding `events-05` |
| `events-06` | `events`, `foundation` | later full `foundation` audit | `Correct event dispatch, queued-consumer isolation, and queue interoperability`; finding `events-06` |
| `queue-11` | `queue` | `events` (revalidation complete), `broadcasting`; later full `queue` and `broadcasting` audits | `Correct event dispatch, queued-consumer isolation, and queue interoperability`; finding `queue-11` |
-| `queue-12` | `bus`, `queue` | `events` (revalidation complete), `broadcasting`; later full `bus`, `queue`, and `broadcasting` audits | `Correct event dispatch, queued-consumer isolation, and queue interoperability`; finding `queue-12` |
+| `queue-12` | `bus`, `queue` | `events` and `bus` (revalidation complete), `broadcasting`; later full `queue` and `broadcasting` audits | `Correct event dispatch, queued-consumer isolation, and queue interoperability`; finding `queue-12` |
| `foundation-01` | `foundation` | `support`; later full `foundation` and `support` audits | `Correct event dispatch, queued-consumer isolation, and queue interoperability`; finding `foundation-01` |
-| `support-02` | `support` | `auth`, `broadcasting`, `bus`, `cache`, `concurrency`, `console`, `container`, `contracts`, `cookie`, `database`, `events`, `filesystem` (revalidation complete), `foundation`, `hashing` (revalidation complete), `horizon`, `inertia`, `jwt`, `log`, `mail`, `notifications`, `permission`, `pipeline`, `queue`, `redis`, `reverb`, `routing`, `sanctum`, `scout`, `session`, `socialite`, `telescope`, `testbench`, `translation`; later full consumer audits | `Normalize framework enum identifiers at string boundaries`; finding `support-02`; sibling findings `translation-01` and `reverb-03`; linked detail plan `2026-07-15-framework-enum-identifier-contracts.md` |
+| `support-02` | `support` | `auth`, `broadcasting`, `bus` (revalidation complete), `cache`, `concurrency`, `console`, `container`, `contracts`, `cookie`, `database`, `events`, `filesystem` (revalidation complete), `foundation`, `hashing` (revalidation complete), `horizon`, `inertia`, `jwt`, `log`, `mail`, `notifications`, `permission`, `pipeline`, `queue`, `redis`, `reverb`, `routing`, `sanctum`, `scout`, `session`, `socialite`, `telescope`, `testbench`, `translation`; later full consumer audits | `Normalize framework enum identifiers at string boundaries`; finding `support-02`; sibling findings `translation-01` and `reverb-03`; linked detail plan `2026-07-15-framework-enum-identifier-contracts.md` |
| `auth-01` | `support`, `auth` | later full `auth` audit | `Correct Support utility boundaries and authentication timing isolation`; finding `auth-01` |
| `encryption-03` | `encryption` | `contracts`, `support`, and `filesystem` (revalidation complete), `foundation`; later full `foundation` audit | `Harden encryption rotation, key publication, and global lifecycle state`; finding `encryption-03` |
| `sanctum-01` | `sanctum` | `encryption`; later full `sanctum` audit | `Harden encryption rotation, key publication, and global lifecycle state`; finding `sanctum-01` |
| `process-02` | `process` | `concurrency` (revalidation complete) | `Make Process callbacks and pools failure-safe`; finding `process-02` |
| `server-process-10` | `server-process` | `foundation`; later full `foundation` audit | `Make custom server processes failure-safe`; finding `server-process-10` |
+| `bus-03` | `bus`, `contracts`, `foundation` | `foundation`, `queue`; later full consumer audits | `Make Bus dispatch, batches, and unique payloads lifecycle-safe`; finding `bus-03` |
+| `bus-10` | `bus`, `queue` | later full `queue` audit | `Make Bus dispatch, batches, and unique payloads lifecycle-safe`; finding `bus-10` |
+| `bus-17` | `bus`, `foundation`, `queue`, `testing` | `log` (revalidation complete), `foundation`, `queue`, `testing`; later full consumer audits | `Make Bus dispatch, batches, and unique payloads lifecycle-safe`; finding `bus-17` |
+| `bus-18` | `foundation`, `queue` | later full `foundation` and `queue` audits | `Make Bus dispatch, batches, and unique payloads lifecycle-safe`; finding `bus-18` |
+| `core-01` | `core`, `foundation` | later full `foundation` audit | `Harden Core lifecycle callbacks and stdout logging`; finding `core-01` |
+| `core-05` | `core`, `foundation` | later full `foundation` audit | `Harden Core lifecycle callbacks and stdout logging`; finding `core-05` |
+| `core-06` | `core`, `server` | later full `server` audit | `Harden Core lifecycle callbacks and stdout logging`; finding `core-06` |
## Package checklist
@@ -1114,9 +1121,9 @@ The order is lower-level first where practical. Hypervel has cross-cutting depen
### Framework dispatch and runtime
-- [ ] `pipeline`
-- [ ] `bus`
-- [ ] `core`
+- [x] `pipeline`
+- [x] `bus`
+- [x] `core`
- [ ] `foundation`
- [ ] `console`
- [ ] `server`
diff --git a/src/boost/docs/deployment.md b/src/boost/docs/deployment.md
index a5ae20dad..2454513b2 100644
--- a/src/boost/docs/deployment.md
+++ b/src/boost/docs/deployment.md
@@ -128,6 +128,8 @@ php artisan serve
By default, the HTTP server binds to `0.0.0.0:8000` with HTTP/2 enabled. You may configure the server host, port, worker count, max requests per worker, HTTP/2 support, and other Swoole settings using the `SERVER_HOST`, `SERVER_PORT`, `SERVER_WORKERS`, `SERVER_MAX_REQUESTS`, and `SERVER_HTTP2` environment variables read by `config/server.php`.
+Swoole's `event_object` setting is not supported because Hypervel dispatches its own lifecycle event objects from the native server callbacks. Leave this setting disabled and use Hypervel's lifecycle events when integrating with server activity.
+
The `serve` command also accepts `--host` and `--port` options for overriding the HTTP server address for the current process. In production, prefer durable configuration in `config/server.php` and your environment.
diff --git a/src/boost/docs/logging.md b/src/boost/docs/logging.md
index 476f91807..9602a279c 100644
--- a/src/boost/docs/logging.md
+++ b/src/boost/docs/logging.md
@@ -113,7 +113,7 @@ Hypervel's default logging configuration includes `stdout` and `stderr` channels
The `stdout` channel writes messages to `php://stdout`, while the `stderr` channel writes messages to `php://stderr`. You may customize their formatters using the `LOG_STDOUT_FORMATTER` and `LOG_STDERR_FORMATTER` environment variables.
-Hypervel also uses a separate low-level stdout logger for server infrastructure such as connection pools, server lifecycle messages, and the response emitter. You may customize this logger's output format using the `STDOUT_LOG_FORMAT` environment variable. Supported values are `line` and `json`.
+Hypervel also uses a separate low-level stdout logger for server infrastructure such as connection pools and server lifecycle messages. Configure its enabled levels using `app.stdout_log.level` and its output format using the `STDOUT_LOG_FORMAT` environment variable. Supported formats are `line` and `json`. Each worker loads these settings when it starts, so reload the server after changing them.
### Logging Deprecation Warnings
diff --git a/src/boost/docs/queues.md b/src/boost/docs/queues.md
index 61d3735a4..3a067e141 100644
--- a/src/boost/docs/queues.md
+++ b/src/boost/docs/queues.md
@@ -19,6 +19,9 @@
- [Dispatching Jobs](#dispatching-jobs)
- [Delayed Dispatching](#delayed-dispatching)
- [Synchronous Dispatching](#synchronous-dispatching)
+ - [Deferred Dispatching](#deferred-dispatching)
+ - [Bulk Dispatching](#bulk-dispatching)
+ - [Preparing Jobs Before Dispatch](#preparing-jobs-before-dispatch)
- [Jobs & Database Transactions](#jobs-and-database-transactions)
- [Job Chaining](#job-chaining)
- [Customizing The Queue and Connection](#customizing-the-queue-and-connection)
@@ -32,6 +35,7 @@
- [Chains and Batches](#chains-and-batches)
- [Adding Jobs to Batches](#adding-jobs-to-batches)
- [Inspecting Batches](#inspecting-batches)
+ - [Batch Events](#batch-events)
- [Cancelling Batches](#cancelling-batches)
- [Batch Failures](#batch-failures)
- [Pruning Batches](#pruning-batches)
@@ -56,6 +60,7 @@
- [Clearing Jobs From Queues](#clearing-jobs-from-queues)
- [Monitoring Your Queues](#monitoring-your-queues)
- [Testing](#testing)
+ - [Testing Bus Dispatches](#testing-bus-dispatches)
- [Faking a Subset of Jobs](#faking-a-subset-of-jobs)
- [Testing Job Chains](#testing-job-chains)
- [Testing Job Batches](#testing-job-batches)
@@ -1171,6 +1176,75 @@ RecordDelivery::dispatch($order)->onConnection('background');
The `background` and `deferred` drivers do not persist jobs to an external queue backend. Delayed jobs on these connections are scheduled with an in-memory timer and will be lost if the worker exits before the timer fires. Use a persistent queue connection such as `database`, `redis`, `sqs`, or `beanstalkd` for durable delayed work.
+You may also chain `afterResponse` onto a dispatch to run the job synchronously when the current coroutine ends:
+
+```php
+ProcessPodcast::dispatch($podcast)->afterResponse();
+```
+
+The method accepts a boolean, which is useful when the choice is conditional. Passing `false` uses the job's normal dispatch path:
+
+```php
+ProcessPodcast::dispatch($podcast)->afterResponse($shouldDefer);
+```
+
+After-response dispatches use the synchronous connection and are not durable.
+
+
+### Bulk Dispatching
+
+If you need to dispatch many independent jobs at once and do not need [batch](#job-batching) tracking or callbacks, you may use the `bulk` method of the `Bus` facade. Hypervel will group the jobs by their configured queue connection and queue name and push each group to the appropriate queue in bulk:
+
+```php
+use App\Jobs\ProcessUser;
+use Hypervel\Support\Facades\Bus;
+
+Bus::bulk(
+ $users->map(fn ($user) => new ProcessUser($user))
+);
+```
+
+Bulk dispatch sends jobs directly to the selected queue driver and does not run the `PreparesForDispatch`, unique job, or debounce dispatch lifecycle. Dispatch jobs that use these features individually.
+
+
+### Preparing Jobs Before Dispatch
+
+If a job needs to prepare or inspect its state before it is pushed onto the queue, the job may implement the `Hypervel\Contracts\Queue\PreparesForDispatch` interface. Hypervel will invoke the job's `prepareForDispatch` method before dispatching the job. If this method returns `false`, the job will not be dispatched; returning `true` or no value allows dispatch to continue:
+
+```php
+podcastIds)
+ ->reject(fn (int $id) => Cache::has("podcast-syncing:{$id}"))
+ ->isNotEmpty();
+ }
+}
+```
+
### Jobs & Database Transactions
@@ -2217,6 +2291,11 @@ Route::get('/batch/{batchId}', function (string $batchId) {
});
```
+
+### Batch Events
+
+Hypervel dispatches events as a batch moves through its lifecycle. You may listen for `BatchDispatched` after a batch is dispatched, `BatchStarted` when its first job is processed, `BatchFinished` when it is marked as finished, and `BatchCanceled` when it is canceled. Each event exposes the batch through its `$batch` property; `BatchCanceled` also exposes the exception that caused cancellation, when available.
+
### Cancelling Batches
@@ -3170,6 +3249,21 @@ Queue::assertClosurePushed(function (CallQueuedClosure $job) {
});
```
+
+### Testing Bus Dispatches
+
+You may use the `Bus` facade to fake command and job dispatches. The `assertNothingDispatched` method checks normal, synchronous, and after-response dispatches:
+
+```php
+use Hypervel\Support\Facades\Bus;
+
+Bus::fake();
+
+// Perform the action under test...
+
+Bus::assertNothingDispatched();
+```
+
### Faking a Subset of Jobs
@@ -3328,6 +3422,16 @@ Bus::assertBatched(function (PendingBatch $batch) {
});
```
+If you only need to assert the batch's jobs, you may pass the expected jobs directly:
+
+```php
+Bus::assertBatched([
+ new ProcessCsvRow(row: 1),
+ new ProcessCsvRow(row: 2),
+ new ProcessCsvRow(row: 3),
+]);
+```
+
The `hasJobs` method may be used on the pending batch to verify that the batch contains the expected jobs. The method accepts an array of job instances, class names, or closures:
```php
diff --git a/src/bus/README.md b/src/bus/README.md
index bab2dc3f0..264db0c72 100644
--- a/src/bus/README.md
+++ b/src/bus/README.md
@@ -1,4 +1,12 @@
Bus for Hypervel
===
-[](https://deepwiki.com/hypervel/bus)
\ No newline at end of file
+[](https://deepwiki.com/hypervel/bus)
+
+Ported from: https://github.com/laravel/framework/tree/13.x/src/Illuminate/Bus
+
+## Differences From Laravel
+
+Hypervel does not include Laravel's DynamoDB batch repository because DynamoDB is not a supported database backend.
+
+`DatabaseBatchRepository::setConnection()` is intentionally omitted. The repository is shared for the worker lifetime, so mutating its connection would race across coroutines. Configure `queue.batching.database` instead; each repository operation resolves that connection when it runs.
diff --git a/src/bus/src/Batch.php b/src/bus/src/Batch.php
index 9f0bf638d..b2f669de5 100644
--- a/src/bus/src/Batch.php
+++ b/src/bus/src/Batch.php
@@ -8,6 +8,7 @@
use Closure;
use Hypervel\Bus\Events\BatchCanceled;
use Hypervel\Bus\Events\BatchFinished;
+use Hypervel\Bus\Events\BatchStarted;
use Hypervel\Container\Container;
use Hypervel\Contracts\Events\Dispatcher;
use Hypervel\Contracts\Queue\Factory as QueueFactory;
@@ -65,10 +66,17 @@ public function add(array|object $jobs): ?Batch
$chain = $this->prepareBatchedChain($job);
- return $chain->first()
- ->allOnQueue($this->options['queue'] ?? null)
- ->allOnConnection($this->options['connection'] ?? null)
- ->chain($chain->slice(1)->values()->all());
+ $first = $chain->first();
+
+ if (isset($this->options['queue'])) {
+ $first->allOnQueue($this->options['queue']);
+ }
+
+ if (isset($this->options['connection'])) {
+ $first->allOnConnection($this->options['connection']);
+ }
+
+ return $first->chain($chain->slice(1)->values()->all());
}
$job->withBatchId($this->id);
@@ -127,6 +135,22 @@ public function recordSuccessfulJob(string $jobId): void
{
$counts = $this->decrementPendingJobs($jobId);
+ if ($counts === null) {
+ return;
+ }
+
+ if ($this->isFirstJobProcessed($counts)) {
+ $container = Container::getInstance();
+
+ if ($container->bound(Dispatcher::class)) {
+ $events = $container->make(Dispatcher::class);
+
+ if ($events->hasListeners(BatchStarted::class)) {
+ $events->dispatch(new BatchStarted($this));
+ }
+ }
+ }
+
if ($this->hasProgressCallbacks()) {
$this->invokeCallbacks('progress');
}
@@ -137,7 +161,11 @@ public function recordSuccessfulJob(string $jobId): void
$container = Container::getInstance();
if ($container->bound(Dispatcher::class)) {
- $container->make(Dispatcher::class)->dispatch(new BatchFinished($this));
+ $events = $container->make(Dispatcher::class);
+
+ if ($events->hasListeners(BatchFinished::class)) {
+ $events->dispatch(new BatchFinished($this));
+ }
}
}
@@ -153,7 +181,7 @@ public function recordSuccessfulJob(string $jobId): void
/**
* Decrement the pending jobs for the batch.
*/
- public function decrementPendingJobs(string $jobId): UpdatedBatchJobCounts
+ public function decrementPendingJobs(string $jobId): ?UpdatedBatchJobCounts
{
return $this->repository->decrementPendingJobs($this->id, $jobId);
}
@@ -165,6 +193,10 @@ protected function invokeCallbacks(string $type, ?Throwable $e = null): void
{
$batch = $this->fresh();
+ if ($batch === null) {
+ return;
+ }
+
foreach ($this->options[$type] ?? [] as $handler) {
$this->invokeHandlerCallback($handler, $batch, $e);
}
@@ -217,8 +249,24 @@ public function recordFailedJob(string $jobId, ?Throwable $e): void
{
$counts = $this->incrementFailedJobs($jobId);
+ if ($counts === null) {
+ return;
+ }
+
+ if ($this->isFirstJobProcessed($counts)) {
+ $container = Container::getInstance();
+
+ if ($container->bound(Dispatcher::class)) {
+ $events = $container->make(Dispatcher::class);
+
+ if ($events->hasListeners(BatchStarted::class)) {
+ $events->dispatch(new BatchStarted($this));
+ }
+ }
+ }
+
if ($counts->failedJobs === 1 && ! $this->allowsFailures()) {
- $this->cancel();
+ $this->cancel($e);
}
if ($this->allowsFailures()) {
@@ -243,11 +291,19 @@ public function recordFailedJob(string $jobId, ?Throwable $e): void
/**
* Increment the failed jobs for the batch.
*/
- public function incrementFailedJobs(string $jobId): UpdatedBatchJobCounts
+ public function incrementFailedJobs(string $jobId): ?UpdatedBatchJobCounts
{
return $this->repository->incrementFailedJobs($this->id, $jobId);
}
+ /**
+ * Determine if this is the first job processed in the batch.
+ */
+ protected function isFirstJobProcessed(UpdatedBatchJobCounts $counts): bool
+ {
+ return $this->totalJobs - $counts->pendingJobs + $counts->failedJobs === 1;
+ }
+
/**
* Determine if the batch has "catch" callbacks.
*/
@@ -275,14 +331,18 @@ public function hasFinallyCallbacks(): bool
/**
* Cancel the batch.
*/
- public function cancel(): void
+ public function cancel(?Throwable $exception = null): void
{
$this->repository->cancel($this->id);
$container = Container::getInstance();
if ($container->bound(Dispatcher::class)) {
- $container->make(Dispatcher::class)->dispatch(new BatchCanceled($this));
+ $events = $container->make(Dispatcher::class);
+
+ if ($events->hasListeners(BatchCanceled::class)) {
+ $events->dispatch(new BatchCanceled($this, $exception));
+ }
}
}
diff --git a/src/bus/src/BatchRepository.php b/src/bus/src/BatchRepository.php
index b351fb341..0d7103040 100644
--- a/src/bus/src/BatchRepository.php
+++ b/src/bus/src/BatchRepository.php
@@ -33,12 +33,12 @@ public function incrementTotalJobs(int|string $batchId, int $amount): void;
/**
* Decrement the total number of pending jobs for the batch.
*/
- public function decrementPendingJobs(int|string $batchId, string $jobId): UpdatedBatchJobCounts;
+ public function decrementPendingJobs(int|string $batchId, string $jobId): ?UpdatedBatchJobCounts;
/**
* Increment the total number of failed jobs for the batch.
*/
- public function incrementFailedJobs(int|string $batchId, string $jobId): UpdatedBatchJobCounts;
+ public function incrementFailedJobs(int|string $batchId, string $jobId): ?UpdatedBatchJobCounts;
/**
* Mark the batch that has the given ID as finished.
@@ -57,6 +57,11 @@ public function delete(int|string $batchId): void;
/**
* Execute the given Closure within a storage specific transaction.
+ *
+ * @template TReturn
+ *
+ * @param Closure(): TReturn $callback
+ * @return TReturn
*/
public function transaction(Closure $callback): mixed;
diff --git a/src/bus/src/Batchable.php b/src/bus/src/Batchable.php
index 377743407..359a84e98 100644
--- a/src/bus/src/Batchable.php
+++ b/src/bus/src/Batchable.php
@@ -30,7 +30,7 @@ public function batch(): ?Batch
return $this->fakeBatch;
}
- if ($this->batchId) {
+ if ($this->batchId !== null && $this->batchId !== '') {
return Container::getInstance()->make(BatchRepository::class)->find($this->batchId);
}
@@ -44,7 +44,7 @@ public function batching(): bool
{
$batch = $this->batch();
- return $batch && ! $batch->cancelled();
+ return $batch && ! $batch->finished() && ! $batch->cancelled();
}
/**
@@ -75,7 +75,7 @@ public function withFakeBatch(
?CarbonImmutable $finishedAt = null,
): array {
$this->fakeBatch = new BatchFake(
- empty($id) ? (string) Str::uuid() : $id,
+ $id === '' ? (string) Str::uuid() : $id,
$name,
$totalJobs,
$pendingJobs,
diff --git a/src/bus/src/BusServiceProvider.php b/src/bus/src/BusServiceProvider.php
index c770b89bb..c1b2199e7 100644
--- a/src/bus/src/BusServiceProvider.php
+++ b/src/bus/src/BusServiceProvider.php
@@ -31,7 +31,7 @@ public function register(): void
);
$this->app->alias(
- Dispatcher::class,
+ DispatcherContract::class,
QueueingDispatcherContract::class,
);
}
@@ -45,11 +45,13 @@ protected function registerBatchServices(): void
return $app->make(DatabaseBatchRepository::class);
});
+ // DynamoDB batch storage is intentionally unsupported because Hypervel does not support DynamoDB databases.
+
$this->app->singleton(DatabaseBatchRepository::class, function ($app) {
return new DatabaseBatchRepository(
$app->make(BatchFactory::class),
$app->make('db'),
- $app->make('config')->string('queue.batching.table', 'job_batches'),
+ $app->make('config')->string('queue.batching.table'),
$app->make('config')->get('queue.batching.database'),
);
});
diff --git a/src/bus/src/DatabaseBatchRepository.php b/src/bus/src/DatabaseBatchRepository.php
index 87c8f8f4a..26767ffc4 100644
--- a/src/bus/src/DatabaseBatchRepository.php
+++ b/src/bus/src/DatabaseBatchRepository.php
@@ -35,10 +35,10 @@ public function __construct(
*/
public function get(int $limit = 50, mixed $before = null): array
{
- return $this->connection()->table($this->table)
+ return $this->getConnection()->table($this->table)
->orderByDesc('id')
->limit($limit)
- ->when($before, fn ($q) => $q->where('id', '<', $before))
+ ->when($before !== null && $before !== '', fn ($q) => $q->where('id', '<', $before))
->get()
->map(function ($batch) {
return $this->toBatch($batch);
@@ -51,7 +51,7 @@ public function get(int $limit = 50, mixed $before = null): array
*/
public function find(int|string $batchId): ?Batch
{
- $batch = $this->connection()->table($this->table)
+ $batch = $this->getConnection()->table($this->table)
->useWritePdo()
->where('id', $batchId)
->first();
@@ -66,7 +66,7 @@ public function store(PendingBatch $batch): Batch
{
$id = (string) Str::orderedUuid();
- $this->connection()->table($this->table)->insert([
+ $this->getConnection()->table($this->table)->insert([
'id' => $id,
'name' => $batch->name,
'total_jobs' => 0,
@@ -93,7 +93,7 @@ public function store(PendingBatch $batch): Batch
*/
public function incrementTotalJobs(int|string $batchId, int $amount): void
{
- $this->connection()->table($this->table)->where('id', $batchId)->update([
+ $this->getConnection()->table($this->table)->where('id', $batchId)->update([
'total_jobs' => new Expression('total_jobs + ' . $amount),
'pending_jobs' => new Expression('pending_jobs + ' . $amount),
'finished_at' => null,
@@ -103,7 +103,7 @@ public function incrementTotalJobs(int|string $batchId, int $amount): void
/**
* Decrement the total number of pending jobs for the batch.
*/
- public function decrementPendingJobs(int|string $batchId, string $jobId): UpdatedBatchJobCounts
+ public function decrementPendingJobs(int|string $batchId, string $jobId): ?UpdatedBatchJobCounts
{
$values = $this->updateAtomicValues($batchId, function ($batch) use ($jobId) {
return [
@@ -113,7 +113,7 @@ public function decrementPendingJobs(int|string $batchId, string $jobId): Update
];
});
- return new UpdatedBatchJobCounts(
+ return $values === null ? null : new UpdatedBatchJobCounts(
$values['pending_jobs'],
$values['failed_jobs']
);
@@ -122,7 +122,7 @@ public function decrementPendingJobs(int|string $batchId, string $jobId): Update
/**
* Increment the total number of failed jobs for the batch.
*/
- public function incrementFailedJobs(int|string $batchId, string $jobId): UpdatedBatchJobCounts
+ public function incrementFailedJobs(int|string $batchId, string $jobId): ?UpdatedBatchJobCounts
{
$values = $this->updateAtomicValues($batchId, function ($batch) use ($jobId) {
return [
@@ -132,7 +132,7 @@ public function incrementFailedJobs(int|string $batchId, string $jobId): Updated
];
});
- return new UpdatedBatchJobCounts(
+ return $values === null ? null : new UpdatedBatchJobCounts(
$values['pending_jobs'],
$values['failed_jobs']
);
@@ -143,13 +143,13 @@ public function incrementFailedJobs(int|string $batchId, string $jobId): Updated
*/
protected function updateAtomicValues(int|string $batchId, Closure $callback): ?array
{
- return $this->connection()->transaction(function () use ($batchId, $callback) {
- $batch = $this->connection()->table($this->table)->where('id', $batchId)
+ return $this->getConnection()->transaction(function () use ($batchId, $callback) {
+ $batch = $this->getConnection()->table($this->table)->where('id', $batchId)
->lockForUpdate()
->first();
- return is_null($batch) ? [] : tap($callback($batch), function ($values) use ($batchId) {
- $this->connection()->table($this->table)->where('id', $batchId)->update($values);
+ return is_null($batch) ? null : tap($callback($batch), function ($values) use ($batchId) {
+ $this->getConnection()->table($this->table)->where('id', $batchId)->update($values);
});
});
}
@@ -159,7 +159,7 @@ protected function updateAtomicValues(int|string $batchId, Closure $callback): ?
*/
public function markAsFinished(int|string $batchId): void
{
- $this->connection()->table($this->table)->where('id', $batchId)->update([
+ $this->getConnection()->table($this->table)->where('id', $batchId)->update([
'finished_at' => time(),
]);
}
@@ -169,7 +169,7 @@ public function markAsFinished(int|string $batchId): void
*/
public function cancel(int|string $batchId): void
{
- $this->connection()->table($this->table)->where('id', $batchId)->update([
+ $this->getConnection()->table($this->table)->where('id', $batchId)->update([
'cancelled_at' => time(),
'finished_at' => time(),
]);
@@ -180,7 +180,7 @@ public function cancel(int|string $batchId): void
*/
public function delete(int|string $batchId): void
{
- $this->connection()->table($this->table)->where('id', $batchId)->delete();
+ $this->getConnection()->table($this->table)->where('id', $batchId)->delete();
}
/**
@@ -188,7 +188,7 @@ public function delete(int|string $batchId): void
*/
public function prune(DateTimeInterface $before): int
{
- $query = $this->connection()->table($this->table)
+ $query = $this->getConnection()->table($this->table)
->whereNotNull('finished_at')
->where('finished_at', '<', $before->getTimestamp());
@@ -208,7 +208,7 @@ public function prune(DateTimeInterface $before): int
*/
public function pruneUnfinished(DateTimeInterface $before): int
{
- $query = $this->connection()->table($this->table)
+ $query = $this->getConnection()->table($this->table)
->whereNull('finished_at')
->where('created_at', '<', $before->getTimestamp());
@@ -228,7 +228,7 @@ public function pruneUnfinished(DateTimeInterface $before): int
*/
public function pruneCancelled(DateTimeInterface $before): int
{
- $query = $this->connection()->table($this->table)
+ $query = $this->getConnection()->table($this->table)
->whereNotNull('cancelled_at')
->where('created_at', '<', $before->getTimestamp());
@@ -245,10 +245,15 @@ public function pruneCancelled(DateTimeInterface $before): int
/**
* Execute the given Closure within a storage specific transaction.
+ *
+ * @template TReturn
+ *
+ * @param Closure(): TReturn $callback
+ * @return TReturn
*/
public function transaction(Closure $callback): mixed
{
- return $this->connection()->transaction(fn () => $callback());
+ return $this->getConnection()->transaction(fn () => $callback());
}
/**
@@ -256,7 +261,7 @@ public function transaction(Closure $callback): mixed
*/
public function rollBack(): void
{
- $this->connection()->rollBack();
+ $this->getConnection()->rollBack();
}
/**
@@ -266,7 +271,7 @@ protected function serialize(mixed $value): string
{
$serialized = serialize($value);
- return $this->connection() instanceof PostgresConnection
+ return $this->getConnection() instanceof PostgresConnection
? base64_encode($serialized)
: $serialized;
}
@@ -276,7 +281,7 @@ protected function serialize(mixed $value): string
*/
protected function unserialize(string $serialized): mixed
{
- if ($this->connection() instanceof PostgresConnection
+ if ($this->getConnection() instanceof PostgresConnection
&& ! Str::contains($serialized, [':', ';'])
) {
$serialized = base64_decode($serialized);
@@ -312,18 +317,10 @@ protected function toBatch(object $batch): Batch
/**
* Get the underlying database connection.
*/
- public function connection(): ConnectionInterface
+ public function getConnection(): ConnectionInterface
{
return $this->resolver->connection($this->connection);
}
- /**
- * Set the connection name to be used.
- */
- public function setConnection(string $connection): static
- {
- $this->connection = $connection;
-
- return $this;
- }
+ // REMOVED: A mutable connection override would race across coroutines on this worker singleton.
}
diff --git a/src/bus/src/DebounceLock.php b/src/bus/src/DebounceLock.php
index 5e21393d5..1f10413a0 100644
--- a/src/bus/src/DebounceLock.php
+++ b/src/bus/src/DebounceLock.php
@@ -58,14 +58,15 @@ protected function maxWaitExceeded(Cache $cache, string $key, int $ttl, ?int $ma
}
$timestampKey = $key . ':first_dispatched_at';
+ $firstDispatchedAt = $cache->get($timestampKey);
- if (! $cache->has($timestampKey)) {
- $cache->put($timestampKey, CarbonImmutable::now()->getTimestamp(), $ttl);
+ if ($firstDispatchedAt === null) {
+ $cache->add($timestampKey, CarbonImmutable::now()->getTimestamp(), $ttl);
return false;
}
- $elapsed = CarbonImmutable::now()->getTimestamp() - $cache->get($timestampKey);
+ $elapsed = CarbonImmutable::now()->getTimestamp() - $firstDispatchedAt;
if ($elapsed >= $maxWait) {
$cache->forget($timestampKey);
@@ -77,19 +78,14 @@ protected function maxWaitExceeded(Cache $cache, string $key, int $ttl, ?int $ma
}
/**
- * Determine if the given owner is the current owner for this debounce key.
+ * Get the current owner for the given job.
*/
- public function isCurrentOwner(mixed $job, string $owner): bool
+ public function getCurrentOwner(mixed $job): ?string
{
- return $this->resolveCache($job)->get(static::getKey($job)) === $owner;
- }
+ /** @var null|string $owner */
+ $owner = $this->resolveCache($job)->get(static::getKey($job));
- /**
- * Determine if a debounce token exists for the given job.
- */
- public function lockExists(mixed $job): bool
- {
- return ! is_null($this->resolveCache($job)->get(static::getKey($job)));
+ return $owner;
}
/**
diff --git a/src/bus/src/Dispatcher.php b/src/bus/src/Dispatcher.php
index 9a772256f..6dbc4838b 100644
--- a/src/bus/src/Dispatcher.php
+++ b/src/bus/src/Dispatcher.php
@@ -115,6 +115,48 @@ public function dispatchNow(mixed $command, mixed $handler = null): mixed
->then($callback);
}
+ /**
+ * Dispatch multiple commands in bulk to their appropriate handlers on the queue.
+ */
+ public function bulk(iterable $jobs): void
+ {
+ $groups = [];
+
+ foreach ($jobs as $job) {
+ if (! $this->queueResolver || ! $this->commandShouldBeQueued($job)) {
+ $this->dispatchNow($job);
+
+ continue;
+ }
+
+ $connection = $this->getAttributeValue($job, Connection::class, 'connection')
+ ?? $this->resolveConnectionFromQueueRoute($job)
+ ?? null;
+
+ $queue = $this->getAttributeValue($job, QueueAttribute::class, 'queue')
+ ?? $this->resolveQueueFromQueueRoute($job)
+ ?? null;
+
+ // Connection and queue names may contain the same delimiter, so keep the route dimensions separate.
+ $connectionKey = $connection ?? '';
+ $queueKey = $queue ?? '';
+
+ $groups[$connectionKey][$queueKey]['connection'] = $connection;
+ $groups[$connectionKey][$queueKey]['queue'] = $queue;
+ $groups[$connectionKey][$queueKey]['jobs'][] = $job;
+ }
+
+ foreach ($groups as $connectionGroups) {
+ foreach ($connectionGroups as $group) {
+ ($this->queueResolver)($group['connection'])->bulk(
+ $group['jobs'],
+ '',
+ $group['queue']
+ );
+ }
+ }
+ }
+
/**
* Attempt to find the batch with the given ID.
*/
diff --git a/src/bus/src/Events/BatchCanceled.php b/src/bus/src/Events/BatchCanceled.php
index 1d7ac46ac..afe566f53 100644
--- a/src/bus/src/Events/BatchCanceled.php
+++ b/src/bus/src/Events/BatchCanceled.php
@@ -5,6 +5,7 @@
namespace Hypervel\Bus\Events;
use Hypervel\Bus\Batch;
+use Throwable;
class BatchCanceled
{
@@ -13,6 +14,7 @@ class BatchCanceled
*/
public function __construct(
public Batch $batch,
+ public ?Throwable $exception = null,
) {
}
}
diff --git a/src/bus/src/Events/BatchStarted.php b/src/bus/src/Events/BatchStarted.php
new file mode 100644
index 000000000..26e7e1b36
--- /dev/null
+++ b/src/bus/src/Events/BatchStarted.php
@@ -0,0 +1,18 @@
+container->make(EventDispatcher::class)->dispatch(
- new BatchDispatched($batch)
- );
+ $events = $this->container->make(EventDispatcher::class);
+
+ if ($events->hasListeners(BatchDispatched::class)) {
+ $events->dispatch(new BatchDispatched($batch));
+ }
return $batch;
}
@@ -350,9 +352,11 @@ protected function dispatchExistingBatch(Batch $batch): void
throw $e;
}
- $this->container->make(EventDispatcher::class)->dispatch(
- new BatchDispatched($batch)
- );
+ $events = $this->container->make(EventDispatcher::class);
+
+ if ($events->hasListeners(BatchDispatched::class)) {
+ $events->dispatch(new BatchDispatched($batch));
+ }
}
/**
diff --git a/src/bus/src/UniqueJobPayloadContext.php b/src/bus/src/UniqueJobPayloadContext.php
new file mode 100644
index 000000000..e59d6f2bd
--- /dev/null
+++ b/src/bus/src/UniqueJobPayloadContext.php
@@ -0,0 +1,83 @@
+
+ */
+ protected static ?WeakMap $metadata = null;
+
+ /**
+ * Register unique job metadata for payload creation.
+ */
+ public static function register(ShouldBeUnique $job): void
+ {
+ // @phpstan-ignore assign.propertyType (PHPStan falsely rejects an empty WeakMap for this invariant closed-shape value.)
+ $metadata = static::$metadata ??= new WeakMap;
+
+ // IMPORTANT: Uses Laravel's keys for cross-framework queue interoperability.
+ $metadata[$job] = [
+ 'laravel_unique_job_cache_store' => static::getCacheStore($job),
+ 'laravel_unique_job_key' => UniqueLock::getKey($job),
+ ];
+ }
+
+ /**
+ * Consume unique job metadata for payload creation.
+ *
+ * @return null|array{laravel_unique_job_cache_store: ?string, laravel_unique_job_key: string}
+ */
+ public static function consume(object $job): ?array
+ {
+ if (static::$metadata === null) {
+ return null;
+ }
+
+ $metadata = static::$metadata;
+
+ if (! isset($metadata[$job])) {
+ if (count($metadata) === 0) {
+ static::$metadata = null;
+ }
+
+ return null;
+ }
+
+ $value = $metadata[$job];
+
+ unset($metadata[$job]);
+
+ if (count($metadata) === 0) {
+ static::$metadata = null;
+ }
+
+ return $value;
+ }
+
+ /**
+ * Determine the cache store used by the unique job to acquire locks.
+ */
+ protected static function getCacheStore(ShouldBeUnique $job): ?string
+ {
+ return method_exists($job, 'uniqueVia')
+ ? $job->uniqueVia()->getName()
+ : config('cache.default');
+ }
+
+ /**
+ * Flush all static state.
+ */
+ public static function flushState(): void
+ {
+ static::$metadata = null;
+ }
+}
diff --git a/src/contracts/src/Bus/QueueingDispatcher.php b/src/contracts/src/Bus/QueueingDispatcher.php
index f607cb675..6eb15433d 100644
--- a/src/contracts/src/Bus/QueueingDispatcher.php
+++ b/src/contracts/src/Bus/QueueingDispatcher.php
@@ -19,6 +19,11 @@ public function findBatch(string $batchId): ?Batch;
*/
public function batch(mixed $jobs): PendingBatch;
+ /**
+ * Dispatch an iterable of jobs in bulk.
+ */
+ public function bulk(iterable $jobs): void;
+
/**
* Dispatch a command to its appropriate handler behind a queue.
*/
diff --git a/src/contracts/src/Queue/PreparesForDispatch.php b/src/contracts/src/Queue/PreparesForDispatch.php
index 85a229bcd..d6734fd8e 100644
--- a/src/contracts/src/Queue/PreparesForDispatch.php
+++ b/src/contracts/src/Queue/PreparesForDispatch.php
@@ -8,6 +8,8 @@ interface PreparesForDispatch
{
/**
* Run preparation logic before dispatch. Return false to abort.
+ *
+ * @return bool|void
*/
- public function prepareForDispatch(): bool;
+ public function prepareForDispatch();
}
diff --git a/src/core/README.md b/src/core/README.md
index 8ebe2d02b..4c757f1df 100644
--- a/src/core/README.md
+++ b/src/core/README.md
@@ -2,3 +2,5 @@ Framework for Hypervel
===
[](https://deepwiki.com/hypervel/framework)
+
+Ported from: https://github.com/hyperf/hyperf/tree/master/src/framework
diff --git a/src/core/composer.json b/src/core/composer.json
index 691946387..53426adff 100644
--- a/src/core/composer.json
+++ b/src/core/composer.json
@@ -30,9 +30,9 @@
},
"require": {
"php": "^8.4",
+ "ext-swoole": "^6.2",
"hypervel/contracts": "^0.4",
"hypervel/coordinator": "^0.4",
- "hypervel/coroutine": "^0.4",
"psr/log": "^3.0",
"symfony/console": "^8.1"
},
diff --git a/src/core/src/Bootstrap/ServerStartCallback.php b/src/core/src/Bootstrap/ServerStartCallback.php
deleted file mode 100644
index 1e8b4574a..000000000
--- a/src/core/src/Bootstrap/ServerStartCallback.php
+++ /dev/null
@@ -1,15 +0,0 @@
-taskEnableCoroutine = $config->boolean('server.settings.task_enable_coroutine', false);
+ $settings = $config->array('server.settings');
+ $taskObject = array_key_exists(Constant::OPTION_TASK_USE_OBJECT, $settings)
+ ? (bool) $settings[Constant::OPTION_TASK_USE_OBJECT]
+ : (bool) ($settings[Constant::OPTION_TASK_OBJECT] ?? false);
+
+ $this->taskUsesObject = $config->boolean('server.settings.' . Constant::OPTION_TASK_ENABLE_COROUTINE)
+ || $taskObject;
}
/**
@@ -24,7 +31,8 @@ public function __construct(protected Dispatcher $dispatcher, Repository $config
*/
public function onTask(Server $server, mixed ...$arguments): void
{
- if ($this->taskEnableCoroutine) {
+ if ($this->taskUsesObject) {
+ /** @var Task $task */
$task = $arguments[0];
} else {
[$taskId, $srcWorkerId, $data] = $arguments;
@@ -38,7 +46,7 @@ public function onTask(Server $server, mixed ...$arguments): void
$this->dispatcher->dispatch($event);
if (! is_null($event->result)) {
- if ($this->taskEnableCoroutine) {
+ if ($this->taskUsesObject) {
$task->finish($event->result);
} else {
$server->finish($event->result);
diff --git a/src/core/src/Bootstrap/WorkerStartCallback.php b/src/core/src/Bootstrap/WorkerStartCallback.php
index 2c0e39fe9..ee99e53c9 100644
--- a/src/core/src/Bootstrap/WorkerStartCallback.php
+++ b/src/core/src/Bootstrap/WorkerStartCallback.php
@@ -12,6 +12,7 @@
use Hypervel\Core\Events\BeforeWorkerStart;
use Hypervel\Core\Events\MainWorkerStart;
use Hypervel\Core\Events\OtherWorkerStart;
+use Hypervel\Core\Logger\StdoutLogger;
use Swoole\Server as SwooleServer;
class WorkerStartCallback
@@ -27,6 +28,10 @@ public function onWorkerStart(SwooleServer $server, int $workerId): void
{
$this->dispatcher->dispatch(new BeforeWorkerStart($server, $workerId));
+ if ($this->logger instanceof StdoutLogger) {
+ $this->logger->reloadConfiguration();
+ }
+
if ($workerId === 0) {
$this->dispatcher->dispatch(new MainWorkerStart($server, $workerId));
} else {
diff --git a/src/core/src/Events/OnReceive.php b/src/core/src/Events/OnReceive.php
index aab665cb1..fb1c7fbb3 100644
--- a/src/core/src/Events/OnReceive.php
+++ b/src/core/src/Events/OnReceive.php
@@ -15,7 +15,7 @@ public function __construct(
public readonly Server $server,
public readonly int $fd,
public readonly int $reactorId,
- public readonly mixed $data,
+ public readonly string $data,
) {
}
}
diff --git a/src/core/src/Exceptions/NotImplementedException.php b/src/core/src/Exceptions/NotImplementedException.php
deleted file mode 100644
index 81b781352..000000000
--- a/src/core/src/Exceptions/NotImplementedException.php
+++ /dev/null
@@ -1,11 +0,0 @@
- OutputInterface::VERBOSITY_QUIET,
- in_array('-vvv', $argv) => OutputInterface::VERBOSITY_DEBUG,
- in_array('-vv', $argv) => OutputInterface::VERBOSITY_VERY_VERBOSE,
- in_array('-v', $argv) => OutputInterface::VERBOSITY_VERBOSE,
- default => match ((int) getenv('SHELL_VERBOSITY')) {
- -1 => OutputInterface::VERBOSITY_QUIET,
- 1 => OutputInterface::VERBOSITY_VERBOSE,
- 2 => OutputInterface::VERBOSITY_VERY_VERBOSE,
- 3 => OutputInterface::VERBOSITY_DEBUG,
- default => OutputInterface::VERBOSITY_NORMAL,
- },
- };
- })()
- );
-
- parent::__construct($output, $verbosityLevelMap, $formatLevelMap);
- }
-}
diff --git a/src/core/src/Logger/StdoutLogger.php b/src/core/src/Logger/StdoutLogger.php
index 096844ceb..ba9b94d0e 100644
--- a/src/core/src/Logger/StdoutLogger.php
+++ b/src/core/src/Logger/StdoutLogger.php
@@ -4,110 +4,126 @@
namespace Hypervel\Core\Logger;
+use DateTimeInterface;
use Hypervel\Contracts\Config\Repository;
use Hypervel\Contracts\Log\StdoutLoggerInterface;
+use InvalidArgumentException;
+use Psr\Log\InvalidArgumentException as PsrInvalidArgumentException;
+use Psr\Log\LoggerTrait;
use Psr\Log\LogLevel;
use Stringable;
+use Symfony\Component\Console\Formatter\OutputFormatter;
use Symfony\Component\Console\Output\ConsoleOutput;
use Symfony\Component\Console\Output\OutputInterface;
-
-use function sprintf;
-use function str_replace;
+use Throwable;
/**
* Low-level PSR-3 logger that writes directly to stdout.
*
- * Used by Swoole server infrastructure (connection pools, server lifecycle,
- * response emitter) that needs logging before the application log stack is
- * available. Supports "line" (human-readable colored) and "json" (structured
- * JSON lines for log aggregators) output formats.
+ * Used by Swoole server infrastructure that needs logging before the application
+ * log stack is available. Supports human-readable line and structured JSON output.
*/
class StdoutLogger implements StdoutLoggerInterface
{
+ use LoggerTrait;
+
+ private const JSON_FLAGS = JSON_UNESCAPED_SLASHES
+ | JSON_UNESCAPED_UNICODE
+ | JSON_PRESERVE_ZERO_FRACTION
+ | JSON_INVALID_UTF8_SUBSTITUTE
+ | JSON_PARTIAL_OUTPUT_ON_ERROR;
+
+ private const STANDARD_LEVELS = [
+ LogLevel::EMERGENCY => true,
+ LogLevel::ALERT => true,
+ LogLevel::CRITICAL => true,
+ LogLevel::ERROR => true,
+ LogLevel::WARNING => true,
+ LogLevel::NOTICE => true,
+ LogLevel::INFO => true,
+ LogLevel::DEBUG => true,
+ ];
+
private OutputInterface $output;
private string $format;
+ /** @var array */
private array $logLevels;
- private array $tags = [
- 'component',
- ];
-
public function __construct(private Repository $config, ?OutputInterface $output = null)
{
$this->output = $output ?? new ConsoleOutput;
- $this->format = $this->config->string('app.stdout_log.format', 'line');
- $this->logLevels = $this->config->array('app.stdout_log.level', []);
- }
-
- public function emergency($message, array $context = []): void
- {
- $this->log(LogLevel::EMERGENCY, $message, $context);
+ $this->reloadConfiguration();
}
- public function alert($message, array $context = []): void
- {
- $this->log(LogLevel::ALERT, $message, $context);
- }
-
- public function critical($message, array $context = []): void
+ /**
+ * Reload the cached stdout logger configuration.
+ *
+ * Boot-only. The cached format and enabled levels affect every subsequent
+ * log entry in the worker.
+ */
+ public function reloadConfiguration(): void
{
- $this->log(LogLevel::CRITICAL, $message, $context);
- }
+ $format = $this->config->string('app.stdout_log.format');
- public function error($message, array $context = []): void
- {
- $this->log(LogLevel::ERROR, $message, $context);
- }
+ if (! in_array($format, ['line', 'json'], true)) {
+ throw new InvalidArgumentException("Unsupported stdout log format [{$format}].");
+ }
- public function warning($message, array $context = []): void
- {
- $this->log(LogLevel::WARNING, $message, $context);
- }
+ $logLevels = [];
- public function notice($message, array $context = []): void
- {
- $this->log(LogLevel::NOTICE, $message, $context);
- }
+ foreach ($this->config->array('app.stdout_log.level') as $level) {
+ if (! is_string($level)) {
+ throw new InvalidArgumentException(sprintf(
+ 'Stdout log levels must be strings, %s given.',
+ get_debug_type($level),
+ ));
+ }
- public function info($message, array $context = []): void
- {
- $this->log(LogLevel::INFO, $message, $context);
- }
+ $logLevels[$level] = true;
+ }
- public function debug($message, array $context = []): void
- {
- $this->log(LogLevel::DEBUG, $message, $context);
+ $this->format = $format;
+ $this->logLevels = $logLevels;
}
/**
* Log a message at the given level.
* @param mixed $level
- * @param mixed $message
*/
- public function log($level, $message, array $context = []): void
+ public function log($level, string|Stringable $message, array $context = []): void
{
- // Check if the log level is allowed
- if (! in_array($level, $this->logLevels, true)) {
+ if (! is_string($level)) {
+ throw new PsrInvalidArgumentException(sprintf(
+ 'Log level must be a string, %s given.',
+ get_debug_type($level),
+ ));
+ }
+
+ if (! isset(self::STANDARD_LEVELS[$level]) && ! isset($this->logLevels[$level])) {
+ throw new PsrInvalidArgumentException("Unknown log level [{$level}].");
+ }
+
+ if (! isset($this->logLevels[$level])) {
return;
}
- $tags = array_intersect_key($context, array_flip($this->tags));
- $context = array_diff_key($context, $tags);
+ $tags = [];
- // Handle objects that are not Stringable
- foreach ($context as $key => $value) {
- if (is_object($value) && ! $value instanceof Stringable) {
- $context[$key] = '