Skip to content
Draft
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 2 additions & 0 deletions config/octane.php
Original file line number Diff line number Diff line change
Expand Up @@ -19,6 +19,7 @@
use Laravel\Octane\Listeners\FlushOnce;
use Laravel\Octane\Listeners\FlushTemporaryContainerInstances;
use Laravel\Octane\Listeners\FlushUploadedFiles;
use Laravel\Octane\Listeners\PropagateAttributeSingletons;
use Laravel\Octane\Listeners\ReportException;
use Laravel\Octane\Listeners\StopWorkerIfNecessary;
use Laravel\Octane\Octane;
Expand Down Expand Up @@ -103,6 +104,7 @@
],

OperationTerminated::class => [
PropagateAttributeSingletons::class,
FlushOnce::class,
FlushTemporaryContainerInstances::class,
// DisconnectFromDatabases::class,
Expand Down
105 changes: 105 additions & 0 deletions src/Listeners/PropagateAttributeSingletons.php
Original file line number Diff line number Diff line change
@@ -0,0 +1,105 @@
<?php

namespace Laravel\Octane\Listeners;

class PropagateAttributeSingletons
{
/**
* Handle the event.
*
* When classes decorated with the #[Singleton] attribute are resolved for
* the first time inside the request sandbox, the container lazily registers
* them as shared bindings and stores their instances – but only on the
* sandbox clone. Because the root application never receives these bindings,
* every subsequent request clone starts fresh, effectively treating the
* singleton as a scoped binding.
*
* This listener runs after each operation (request/task/tick) and copies any
* attribute-detected singleton bindings AND their resolved instances from
* the sandbox back to the root application, so that future clones inherit
* them and behave identically to singletons registered via a service provider.
*
* @param mixed $event
*/
public function handle($event): void
{
$sandbox = $event->sandbox;
$app = $event->app;

// The checkedForSingletonOrScopedAttributes property was introduced in
// Laravel 11 alongside the #[Singleton] attribute. On older versions
// there is nothing to propagate, so bail out early.
if (! property_exists($sandbox, 'checkedForSingletonOrScopedAttributes')) {
return;
}

// Access the protected attribute cache from the sandbox via reflection.
$checkedAttributes = $this->getProtectedProperty($sandbox, 'checkedForSingletonOrScopedAttributes');

if (empty($checkedAttributes)) {
return;
}

// For each class that was detected as a singleton via the attribute,
// register the binding and instance on the root app if not already present.
foreach ($checkedAttributes as $className => $type) {
if ($type !== 'singleton') {
continue;
}

// Skip if the root app already has this as a shared binding or instance.
if (isset($app[$className]) && $app->isShared($className)) {
continue;
}

// Register as singleton on root app.
if (! $app->bound($className)) {
$app->singleton($className);
}

// If the sandbox resolved an instance, copy it to the root app
// so clones won't need to rebuild it.
if ($sandbox->resolved($className)) {
$instances = $this->getProtectedProperty($sandbox, 'instances');

if (array_key_exists($className, $instances)) {
$app->instance($className, $instances[$className]);
}
}
}

// Also propagate the attribute cache itself so the root app skips
// reflection on future clones.
$rootChecked = $this->getProtectedProperty($app, 'checkedForSingletonOrScopedAttributes');
$merged = array_merge($rootChecked, $checkedAttributes);
$this->setProtectedProperty($app, 'checkedForSingletonOrScopedAttributes', $merged);
}

/**
* Get a protected property from an object via reflection.
*
* @param object $object
* @param string $property
* @return mixed
*/
protected function getProtectedProperty(object $object, string $property): mixed
{
$reflection = new \ReflectionProperty($object, $property);

return $reflection->getValue($object);
}

/**
* Set a protected property on an object via reflection.
*
* @param object $object
* @param string $property
* @param mixed $value
*/
protected function setProtectedProperty(object $object, string $property, mixed $value): void
{
$reflection = new \ReflectionProperty($object, $property);

$reflection->setValue($object, $value);
}
}
1 change: 1 addition & 0 deletions src/OctaneServiceProvider.php
Original file line number Diff line number Diff line change
Expand Up @@ -150,6 +150,7 @@ protected function bindListeners()
$this->app->singleton(Listeners\PrepareLivewireForNextOperation::class);
$this->app->singleton(Listeners\PrepareScoutForNextOperation::class);
$this->app->singleton(Listeners\PrepareSocialiteForNextOperation::class);
$this->app->singleton(Listeners\PropagateAttributeSingletons::class);
$this->app->singleton(Listeners\ReportException::class);
$this->app->singleton(Listeners\StopWorkerIfNecessary::class);
}
Expand Down
153 changes: 153 additions & 0 deletions tests/AttributeSingletonPropagationTest.php
Original file line number Diff line number Diff line change
@@ -0,0 +1,153 @@
<?php

namespace Laravel\Octane\Tests;

use Illuminate\Container\Attributes\Scoped;
use Illuminate\Container\Attributes\Singleton;
use Illuminate\Foundation\Application;
use Illuminate\Http\Request;

class AttributeSingletonPropagationTest extends TestCase
{
protected function setUp(): void
{
parent::setUp();

// The #[Singleton] attribute was introduced in Laravel 11.
if (! class_exists(\Illuminate\Container\Attributes\Singleton::class)) {
$this->markTestSkipped('Requires Laravel 11+ for #[Singleton] attribute support.');
}
}

public function test_singleton_attribute_classes_persist_across_requests()
{
[$app, $worker, $client] = $this->createOctaneContext([
Request::create('/first', 'GET'),
Request::create('/first', 'GET'),
Request::create('/first', 'GET'),
]);

$app['router']->get('/first', function (Application $app) {
$instance = $app->make(AttributeSingletonService::class);

return spl_object_hash($instance);
});

$worker->run();

// All three requests should receive the same singleton instance
// because the PropagateAttributeSingletons listener copies the
// binding and instance back to the root application.
$this->assertEquals(
$client->responses[0]->original,
$client->responses[1]->original,
);

$this->assertEquals(
$client->responses[1]->original,
$client->responses[2]->original,
);
}

public function test_singleton_attribute_constructor_only_called_once()
{
[$app, $worker, $client] = $this->createOctaneContext([
Request::create('/first', 'GET'),
Request::create('/first', 'GET'),
]);

// Reset static counter.
AttributeSingletonWithCounter::$constructCount = 0;

$app['router']->get('/first', function (Application $app) {
$app->make(AttributeSingletonWithCounter::class);

return AttributeSingletonWithCounter::$constructCount;
});

$worker->run();

// First request: constructor called once.
$this->assertEquals(1, $client->responses[0]->original);
// Second request: still only called once (same instance reused).
$this->assertEquals(1, $client->responses[1]->original);
}

public function test_multiple_resolves_in_same_request_return_same_instance()
{
[$app, $worker, $client] = $this->createOctaneContext([
Request::create('/first', 'GET'),
]);

$app['router']->get('/first', function (Application $app) {
$a = $app->make(AttributeSingletonService::class);
$b = $app->make(AttributeSingletonService::class);
$c = $app->make(AttributeSingletonService::class);

return [
spl_object_hash($a),
spl_object_hash($b),
spl_object_hash($c),
];
});

$worker->run();

$hashes = $client->responses[0]->original;
$this->assertEquals($hashes[0], $hashes[1]);
$this->assertEquals($hashes[1], $hashes[2]);
}

public function test_scoped_attribute_classes_are_not_propagated_to_root_app()
{
[$app, $worker, $client] = $this->createOctaneContext([
Request::create('/first', 'GET'),
Request::create('/first', 'GET'),
Request::create('/first', 'GET'),
]);

$app['router']->get('/first', function (Application $app) {
$instance = $app->make(AttributeScopedService::class);

return spl_object_hash($instance);
});

$worker->run();

// Each request should get a fresh scoped instance because the
// PropagateAttributeSingletons listener intentionally skips
// classes with the #[Scoped] attribute.
$this->assertNotEquals(
$client->responses[0]->original,
$client->responses[1]->original,
);

$this->assertNotEquals(
$client->responses[1]->original,
$client->responses[2]->original,
);
}
}

#[Scoped]
class AttributeScopedService
{
//
}

#[Singleton]
class AttributeSingletonService
{
//
}

#[Singleton]
class AttributeSingletonWithCounter
{
public static int $constructCount = 0;

public function __construct()
{
static::$constructCount++;
}
}
Loading