Skip to content

Commit 4955284

Browse files
authored
Merge pull request #719 from hypervel/upstream-sync-2026-11-01
Sync Laravel updates for queues, database, JSON:API and FTP
2 parents 985a52e + 5617484 commit 4955284

98 files changed

Lines changed: 3204 additions & 183 deletions

File tree

Some content is hidden

Large Commits have some content hidden by default. Use the searchbox below for content that may be hidden.

‎.github/docker/ci/Dockerfile‎

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -95,6 +95,9 @@ RUN set -eux; \
9595

9696
RUN printf '%s\n' 'swoole.use_shortname=Off' > /usr/local/etc/php/conf.d/99-swoole-shortname.ini
9797

98+
# The development Composer root declares the FTP capability supplied by Swoole.
99+
RUN php -r 'if (! function_exists("ftp_connect") || extension_loaded("ftp")) { fwrite(STDERR, "Swoole FTP support without the separate FTP extension is required for filesystem tests.\n"); exit(1); }'
100+
98101
RUN set -eux; \
99102
test "$(php -r 'echo PHP_MAJOR_VERSION, ".", PHP_MINOR_VERSION;')" = "${PHP_VERSION}"; \
100103
test "$(php -r 'echo phpversion("swoole");')" = "${SWOOLE_VERSION}"; \

‎AGENTS.md‎

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -794,9 +794,9 @@ When porting Laravel packages, whether first-party or third-party, keep them as
794794
- Not porting deprecated upstream code or backwards-compatibility shims for versions/features Hypervel does not support — Hypervel is a new framework without Laravel's backwards-compatibility burden, so deprecated APIs and compatibility code that exist only to support older versions should be omitted rather than ported. Before changing or removing a deprecated public Laravel API, reach consensus on the recommended approach through a second-opinion loop with your peer agent. Then STOP, explain the proposed difference and its rationale to the owner, and wait for approval. Here, "upstream" means the framework or package being ported, not one of its dependencies — a Symfony deprecation does not make a Laravel API deprecated while Laravel still retains it. If a deprecated upstream surface still contains behavior that Hypervel actively needs, keep the behavior but move it onto the correct non-deprecated Hypervel-owned surface instead of porting the deprecated alias/wrapper as-is.
795795
- General performance improvements — reach consensus on the expected benefit, correctness, and complexity tradeoffs through a second-opinion loop with your peer agent before implementing.
796796

797-
Hypervel has no obligation to preserve Hypervel-specific behavior from earlier versions, but supported Laravel APIs—including named arguments and protected extension points—must remain compatible unless the user approves a difference. If a Laravel API is unsuitable for Hypervel or preserving it would make the code worse, reach consensus on the cleanest design through a second-opinion loop with your peer agent. Then STOP, explain the proposed difference and its rationale to the owner, and wait for approval before changing it.
797+
Hypervel has no obligation to preserve Hypervel-specific behavior from earlier versions, but supported Laravel APIs—including named arguments and protected extension points—must remain compatible unless the user approves a difference. Compatibility means preserving supported behavior, not merely matching names and signatures. Before recommending a departure, investigate the simplest compatible adaptation and reasonable alternatives, and establish the concrete obstacle. Resolve uncertainty through focused tests or prototypes; an assumed architectural conflict or peer agreement is not sufficient evidence. If preserving compatibility would compromise correctness, performance or scalability, or require disproportionate complexity, reach consensus on the cleanest design through a second-opinion loop with your peer agent. Then STOP, explain the proposed difference, supporting evidence and rationale to the owner, and wait for approval before changing it.
798798

799-
When Laravel or a tracked upstream introduces an equivalent to a Hypervel-specific enhancement, compare their capabilities, APIs, coroutine safety and performance, then reach consensus on how to reconcile them through a second-opinion loop with your peer agent. Normally prefer the upstream API when it meets Hypervel’s requirements. Before deprecating or removing the Hypervel-specific API, STOP, explain the recommendation to the owner, and wait for approval.
799+
When Laravel or a tracked upstream introduces an equivalent to a Hypervel-specific enhancement or fix, compare their capabilities, APIs, coroutine safety and performance, then reach consensus on how to reconcile them through a second-opinion loop with your peer agent. Normally prefer upstream when it meets Hypervel’s requirements. Before deprecating or removing the Hypervel-specific API, STOP, explain the recommendation to the owner, and wait for approval.
800800

801801
Approved adaptations take precedence over upstream fidelity. Preserve Laravel upstream naming, structure, and style everywhere else.
802802

‎composer.json‎

Lines changed: 8 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -126,7 +126,9 @@
126126
"fakerphp/faker": "^1.24",
127127
"friendsofphp/php-cs-fixer": "^3.57.2",
128128
"intervention/image": "^4.2",
129+
"league/flysystem-ftp": "^3.36",
129130
"league/flysystem-google-cloud-storage": "^3.0",
131+
"league/flysystem-sftp-v3": "^3.36",
130132
"meilisearch/meilisearch-php": "^1.16",
131133
"mockery/mockery": "^1.6.15",
132134
"opis/json-schema": "^2.6.0",
@@ -145,6 +147,9 @@
145147
"typesense/typesense-php": "^5.2",
146148
"web-token/jwt-library": "^4.2.3"
147149
},
150+
"provide": {
151+
"ext-ftp": "*"
152+
},
148153
"replace": {
149154
"hypervel/ai": "self.version",
150155
"hypervel/api-client": "self.version",
@@ -235,7 +240,9 @@
235240
},
236241
"conflict": {
237242
"aws/aws-sdk-php": "<3.369.1",
238-
"intervention/image": "<4.0 || >=5.0"
243+
"intervention/image": "<4.0 || >=5.0",
244+
"league/flysystem-ftp": "<3.13",
245+
"league/flysystem-sftp-v3": "<3.13"
239246
},
240247
"minimum-stability": "dev",
241248
"prefer-stable": true,

‎docs/todo.md‎

Lines changed: 0 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -41,10 +41,6 @@
4141
- Add native return types to the `test*` methods that lack them: about 5,600 methods in 490 files. `AGENTS.md` requires `: void` on test methods; a test that returns a value for `#[Depends]` takes its actual return type instead. As with method titles, untyped methods in existing files lead new tests to copy them, so make the change as one approved sweep and enforce it with the same automated check.
4242
- Integrate the resolution of [PHPUnit #7039](https://github.com/sebastianbergmann/phpunit/issues/7039) into `RunTestsInCoroutine` once PHPUnit 13.5 is released ([scheduled for December 4, 2026](https://github.com/sebastianbergmann/phpunit/blob/main/ChangeLog-13.5.md)); it adds `TestCase::suspendOutputBuffering()` and `resumeOutputBuffering()` ([PR #7040](https://github.com/sebastianbergmann/phpunit/pull/7040)). Follow the merged upstream design, including calling `parent::invokeTestMethod()` if capture is handled there. Add regression coverage for output expectations, flushed output and buffer cleanup; update the testing guidance and reassess output-related coroutine opt-outs. Lift the `~13.4.3` PHPUnit constraints in the root, Testbench and dogfood manifests, the testing package's suggestion and the `hypervel/hypervel` skeleton once Testbench's PHPUnit integration is verified on 13.5. Do not add a private-buffer adapter, maintained fork or ongoing Composer/vendor patches. Prefer supported subclass or method overrides; use AOP only as an absolute last resort. Never document a confirmed defect as an accepted limitation.
4343

44-
## Filesystem
45-
46-
- Investigate FTP support with Swoole's built-in coroutine FTP implementation. It supplies `ftp_*` functions but is not discoverable as `ext-ftp`, so Composer rejects `league/flysystem-ftp` and `RequiresPhpExtension('ftp')` skips the driver test. Resolve normal development and production installation without bypassing dependency checks, then add the adapter to root `require-dev`, use a test requirement that accepts either FTP implementation, and update the installation guidance. Basic transfers through Hypervel and Flysystem have been verified inside a Swoole coroutine.
47-
4844
## AI and MCP
4945

5046
- Revisit conversation-history query plans when production evidence or newer database versions warrant it; this does not block the AI port. Reproduce with the stock migration, one oldest conversation containing 10,000 messages, then 999 other participants/conversations with 20 newer messages each. After updating statistics, inspect actual execution of `DatabaseConversationStore::latestConversationId()`, `getLatestConversationMessages(..., 100)` and the first/subsequent cursor pages. MySQL 9.5 and PostgreSQL chose reverse primary-key scans, examining 20,080 rows for 100 messages despite `(conversation_id, id)` being available; smaller targets at roughly 1% of the table used conversation indexes and did not scan other conversations. MariaDB 10.11 read history efficiently but filtered cursor IDs after seeking only the conversation, scanning preceding pages within it; SQLite 3.45 used both index bounds. The query shape matches Laravel's. Selecting IDs first, including through a bounded subquery joined back to messages, did not fix the MySQL/PostgreSQL plans. Include the similar latest-row queries in `pendingApprovalsFor()` and `pausedRowFor()` when investigating; their execution plans have not been measured. Test an index-derived scalar upper bound on message IDs with both oldest-only and interleaved conversations. A stored pointer needs maintenance across all supported writes; a stale low pointer would hide messages. Keep current queries and indexes unless a measured improvement preserves other workloads, configured/partitioned schemas and pagination behavior without unnecessary round trips or database-specific machinery.

‎skeletons/hypervel/config/broadcasting.php‎

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -87,6 +87,7 @@
8787
],
8888
'cookie_name' => env('MERCURE_COOKIE_NAME'),
8989
'subscribe_expiration' => (float) env('MERCURE_SUBSCRIBE_EXPIRATION', 5),
90+
'async' => (bool) env('MERCURE_ASYNC', false),
9091
],
9192

9293
'redis' => [

‎skeletons/react-starter-kit/config/broadcasting.php‎

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -87,6 +87,7 @@
8787
],
8888
'cookie_name' => env('MERCURE_COOKIE_NAME'),
8989
'subscribe_expiration' => (float) env('MERCURE_SUBSCRIBE_EXPIRATION', 5),
90+
'async' => (bool) env('MERCURE_ASYNC', false),
9091
],
9192

9293
'redis' => [

‎src/broadcasting/src/BroadcastEvent.php‎

Lines changed: 16 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -6,6 +6,7 @@
66

77
use Hypervel\Bus\Queueable;
88
use Hypervel\Contracts\Broadcasting\Factory as BroadcastingFactory;
9+
use Hypervel\Contracts\Broadcasting\ShouldRescue;
910
use Hypervel\Contracts\Queue\ShouldQueue;
1011
use Hypervel\Contracts\Queue\ShouldQueueViaOutbox;
1112
use Hypervel\Contracts\Support\Arrayable;
@@ -203,6 +204,21 @@ public function failed(?Throwable $e = null): void
203204
$this->event->failed($e);
204205
}
205206

207+
/**
208+
* Handle a deferred dispatch or transaction cleanup failure.
209+
*
210+
* @internal
211+
*/
212+
public function deferredDispatchFailed(Throwable $exception): void
213+
{
214+
if (! $this->event instanceof ShouldRescue) {
215+
throw $exception;
216+
}
217+
218+
// The manager's rescue callback has returned before deferred queue work runs.
219+
rescue(static fn () => throw $exception);
220+
}
221+
206222
/**
207223
* Get the display name for the queued job.
208224
*/

‎src/broadcasting/src/BroadcastManager.php‎

Lines changed: 60 additions & 10 deletions
Original file line numberDiff line numberDiff line change
@@ -31,7 +31,9 @@
3131
use Hypervel\Contracts\ObjectPool\Factory as PoolFactory;
3232
use Hypervel\Contracts\ObjectPool\InvalidatesPool;
3333
use Hypervel\Contracts\Queue\Factory as Queue;
34+
use Hypervel\Contracts\Queue\ShouldQueueViaOutbox;
3435
use Hypervel\Contracts\Redis\Factory as RedisFactory;
36+
use Hypervel\Database\DatabaseTransactionsManager;
3537
use Hypervel\Foundation\Http\Middleware\PreventRequestForgery;
3638
use Hypervel\Http\Client\Guzzle\HandlerFactory;
3739
use Hypervel\Http\Request;
@@ -199,9 +201,13 @@ public function event(mixed $event = null): PendingBroadcast
199201
*/
200202
public function queue(mixed $event): void
201203
{
202-
if ($event instanceof ShouldBroadcastNow
203-
|| (is_object($event) && method_exists($event, 'shouldBroadcastNow') && $event->shouldBroadcastNow())
204-
) {
204+
$immediate = $event instanceof ShouldBroadcastNow
205+
|| (is_object($event) && method_exists($event, 'shouldBroadcastNow') && $event->shouldBroadcastNow());
206+
$synchronousMercure = ! $immediate
207+
&& ! ($event->viaOutbox ?? $event instanceof ShouldQueueViaOutbox)
208+
&& $this->broadcastsToSynchronousMercureConnections($event);
209+
210+
if ($immediate || ($synchronousMercure && ! $event instanceof ShouldBeUnique && empty($event->afterCommit))) {
205211
$dispatch = fn () => $this->app->make(Dispatcher::class)->dispatchNow(
206212
new BroadcastEvent($event instanceof UnitEnum ? $event : clone $event)
207213
);
@@ -233,13 +239,15 @@ public function queue(mixed $event): void
233239
return;
234240
}
235241

236-
$push = fn () => $this->app->make(Queue::class)
237-
->connection(
238-
$event->connection
239-
?? $this->getAttributeValue($event, ConnectionAttribute::class, 'connection')
240-
?? $this->resolveConnectionFromQueueRoute($event, $queue)
241-
)
242-
->pushOn($queue, $broadcastEvent);
242+
$push = $synchronousMercure
243+
? fn () => $this->dispatchSynchronousBroadcast($broadcastEvent)
244+
: fn () => $this->app->make(Queue::class)
245+
->connection(
246+
$event->connection
247+
?? $this->getAttributeValue($event, ConnectionAttribute::class, 'connection')
248+
?? $this->resolveConnectionFromQueueRoute($event, $queue)
249+
)
250+
->pushOn($queue, $broadcastEvent);
243251

244252
if ($event instanceof ShouldBeUnique) {
245253
$dispatch = $push;
@@ -257,6 +265,48 @@ public function queue(mixed $event): void
257265
: $push();
258266
}
259267

268+
/**
269+
* Publish synchronously while honoring commit timing and dispatch locks.
270+
*/
271+
protected function dispatchSynchronousBroadcast(BroadcastEvent $event): void
272+
{
273+
$dispatch = fn () => $this->app->make(Dispatcher::class)->dispatchNow($event);
274+
275+
if ($event->afterCommit && $this->app->bound('db.transactions')) {
276+
/** @var DatabaseTransactionsManager $transactions */
277+
$transactions = $this->app->make('db.transactions');
278+
279+
if ($transactions->hasApplicableTransaction()) {
280+
// Direct publishing retains the event without queue serialization or model reloads.
281+
$release = static function () use ($event): void {
282+
DispatchLockContext::claim($event);
283+
DispatchLockContext::release($event);
284+
};
285+
$transactions->addCallbackForRollback(fn () => $event->event instanceof ShouldRescue
286+
? $this->rescue($release)
287+
: $release());
288+
289+
$publish = static function () use ($event, $dispatch): void {
290+
DispatchLockContext::claim($event);
291+
292+
try {
293+
$dispatch();
294+
} finally {
295+
DispatchLockContext::release($event);
296+
}
297+
};
298+
$transactions->addCallback(fn () => $event->event instanceof ShouldRescue
299+
? $this->rescue($publish)
300+
: $publish());
301+
DispatchLockContext::delegate($event);
302+
303+
return;
304+
}
305+
}
306+
307+
$dispatch();
308+
}
309+
260310
/**
261311
* Get the container that owns the queue routes.
262312
*/

‎src/broadcasting/src/Mercure/CreatesMercureDrivers.php‎

Lines changed: 25 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -18,6 +18,8 @@
1818
use Symfony\Component\Mercure\Jwt\WebTokenFactory;
1919
use Symfony\Contracts\HttpClient\HttpClientInterface;
2020

21+
use function Hypervel\Support\enum_value;
22+
2123
trait CreatesMercureDrivers
2224
{
2325
protected const string DEFAULT_MERCURE_ALGORITHM = 'HS256';
@@ -105,6 +107,29 @@ public function mercure(array $config): HubInterface
105107

106108
// REMOVED: frankenPhpMercure() calls FrankenPHP's in-process hub; Hypervel uses Swoole and publishes over HTTP.
107109

110+
/**
111+
* Determine if the given event only targets Mercure connections publishing synchronously.
112+
*/
113+
protected function broadcastsToSynchronousMercureConnections(mixed $event): bool
114+
{
115+
$connections = method_exists($event, 'broadcastConnections')
116+
? $event->broadcastConnections()
117+
: [null];
118+
119+
foreach ($connections as $connection) {
120+
$connection = enum_value($connection);
121+
$config = $this->getConfig($connection === null || $connection === ''
122+
? $this->getDefaultDriver()
123+
: (string) $connection);
124+
125+
if (($config['driver'] ?? null) !== 'mercure' || ! empty($config['async'])) {
126+
return false;
127+
}
128+
}
129+
130+
return $connections !== [];
131+
}
132+
108133
/**
109134
* Identify the shared HTTP-client pool for construction and invalidation.
110135
*/

‎src/broadcasting/src/UniqueBroadcastEvent.php‎

Lines changed: 3 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -7,6 +7,7 @@
77
use Hypervel\Container\Container;
88
use Hypervel\Contracts\Cache\Repository;
99
use Hypervel\Contracts\Queue\ShouldBeUnique;
10+
use Hypervel\Queue\Attributes\UniqueFor;
1011

1112
class UniqueBroadcastEvent extends BroadcastEvent implements ShouldBeUnique
1213
{
@@ -33,8 +34,8 @@ public function __construct(mixed $event)
3334

3435
if (method_exists($event, 'uniqueFor')) {
3536
$this->uniqueFor = $event->uniqueFor();
36-
} elseif (property_exists($event, 'uniqueFor')) {
37-
$this->uniqueFor = $event->uniqueFor;
37+
} else {
38+
$this->uniqueFor = $this->getAttributeValue($event, UniqueFor::class, 'uniqueFor', $this->uniqueFor);
3839
}
3940

4041
parent::__construct($event);

0 commit comments

Comments
 (0)