Skip to content

[13.x] RFC: Lazy Services - New #[Lazy] Attribute #55645

New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Draft
wants to merge 1 commit into
base: master
Choose a base branch
from

Conversation

olekjs
Copy link
Contributor

@olekjs olekjs commented May 4, 2025

In short

A new attribute #[Lazy] has been introduced, which defers the initialization of a class until it is actually used.

Example

Let’s assume we have a service that creates a connection to Redis.

namespace App\Services;

use Illuminate\Container\Attributes\Lazy;
use Illuminate\Contracts\Redis\Factory;
use Predis\Client;

class RedisService
{
    private Client $client;

    public function __construct(private Factory $redisFactory)
    {
        $this->client = $this->redisFactory->connection()->client();

        var_dump('Redis client loaded');

        // ...
    }
    
    // ...
}

The above service is used in a controller:

namespace App\Http\Controllers;

use App\Services\RedisService;
use Illuminate\Http\JsonResponse;

class ProductController extends Controller
{
    public function __construct(
        private RedisService $redisService
    ) {
    }

    public function index(): JsonResponse
    {
        $products = Product::query()->take(10)->get();

        return response()->json(['products' => $products]);
    }

    public function show(int $productId): JsonResponse
    {
        $product = $this->redisService->getProduct($productId);

        if (null === $product) {
            $product = Product::query()->find($productId);
        }

        // …

        return response()->json(['product' => $product]);
    }
}

In short. I have a service that handles the Redis connection. I have a controller that handles two endpoints. The first returns a list of products (Redis is not used). The second returns a product object – Redis is used as a cache.

In the current form, there is no Lazy Services support. Traditionally, I make a request to the product listing endpoint:

image

You can see that even though Redis is not used in the product listing endpoint, Redis is still initialized.

Now let’s add the previously mentioned “#[Lazy]” attribute to the service.

namespace App\Services;

use Illuminate\Container\Attributes\Lazy;
use Illuminate\Contracts\Redis\Factory;
use Predis\Client;

#[Lazy]
class RedisService
{
    private Client $client;

    public function __construct(private Factory $redisFactory)
    {
        $this->client = $this->redisFactory->connection()->client();

        var_dump('Redis client loaded');

        // ...
    }
    
    // ...
}

As you can see above. I added the special “#[Lazy]” attribute, which marks the class as Lazy, meaning it will be a Lazy Object in the container until the class is used.

Let’s see what happens now:

image

Send a request to the products endpoint skips loading Redis, why? Because it’s in the container as a Lazy Object. Only using Redis – in this case, fetching a single product – will trigger Redis to load.

Discussion and usage

In 2018 there was a proposal to implement Lazy Service in Laravel. On that occasion, a discussion arose:

As mentioned in the linked discussion above, Lazy Services are already implemented in:

First, a lot has changed since 2018, and second, it was just a proposal. In the meantime, Lazy Objects have been natively supported by PHP since version 8.4.

I decided to create a working POC and possibly start a discussion.

Challenges and future development opportunities

  • Proxies are not easily noticeable to the developer; it's unclear whether it's a regular object or a lazy one – this can only be inferred from behavior or a var_dump,
  • Due to the structure of the class and its dependencies in the container, I haven’t been able to add support for attributes on parameters at this moment:
class ProductController
{
    public function __construct(
        #[Lazy] public RedisService $redisService
    ) {
    }
}

class RedisService
{
    public function __construct()
    {
        throw new \RuntimeException('Lazy call');
    }
}

Here’s the Proof of Concept, and I’d appreciate your feedback. Feel free to join the discussion about this feature and whether there is a place for it in Laravel.

@deleugpn
Copy link
Contributor

deleugpn commented May 4, 2025

If you move the redis connection out of the constructor and only establish a connection when a method that will interact with redis is actually called, then everything would work as you’d expect without the “invisible” downside of having to dig into the underlying class to see the attribute and understand its implications on the Laravel container.

Laravel container is already extremely powerful and factory-driven in the sense that only when a class needs to be instantiated it incur overhead, so I don’t think this is a positive change to the framework overall.

@rodrigopedra
Copy link
Contributor

In your controller example, one could use method injection instead of a constructor injection:

public function show(RedisService $redisService, int $productId): JsonResponse
{
    $product = $redisService->getProduct($productId);

    // …
}

Furthermore, I agree with @deleugpn, and can't figure out places where this would bring more value, than what we already get from Laravel's container.

Comment on lines +1063 to +1065
$instance = $reflector->newLazyProxy(function () use ($concrete, $instances) {
return new $concrete(...$instances);
});
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

You could do just

Suggested change
$instance = $reflector->newLazyProxy(function () use ($concrete, $instances) {
return new $concrete(...$instances);
});
$instance = $reflector->newLazyProxy(fn () => new $concrete(...$instances));

@@ -1058,8 +1059,16 @@ public function build($concrete)

array_pop($this->buildStack);

if (!empty($reflector->getAttributes(Lazy::class))) {
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

You could just directly check this

Suggested change
if (!empty($reflector->getAttributes(Lazy::class))) {
if ($reflector->getAttributes(Lazy::class) !== []) {

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Does this change bring anything beyond syntax? I’m just asking out of curiosity.

Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Not much. Technically, a function call is more expensive than a comparison, but this should mostly be optimized away in practice. One slight advantage would be a clear communication that we are working with an array here and don't have to check any structure's emptiness.

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

@olekjs empty function is a trap for future bugs.
We have a rule in place to never use empty function because of that.

empty('0') is true for example.

@DarkGhostHunter
Copy link
Contributor

I would agree that Lazy Objects support would be great, especially for services that require a huge set up when instanced.

The main problem I have with Lazy objects is testing. Are these completely compatible with Mockery?

@@ -15,7 +15,7 @@
}
],
"require": {
"php": "^8.3",
"php": "^8.4",
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Laravel 13 will depends on PHP 8.3 as minimum.

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Alrighty, in that case we can consider version 14.x, but any potential merging should wait until 13.x is released - is that correct? :)

@olekjs
Copy link
Contributor Author

olekjs commented May 5, 2025

@deleugpn Thank you for the feedback :) I partially agree with you. However, I don’t fully understand what you mean by "only when a class needs to be instantiated it incurs overhead" - could you elaborate?

@rodrigopedra Thanks for the feedback as well. It's great that you and @deleugpn provided an alternative approach to the example - it makes sense. However, it's just a simple example. I'm also considering a case where we have a class overloaded with dependencies, such as Carbon.

Let’s be honest - Carbon is a heavy class and sometimes it can even freeze the IDE.

I’m referring to lazy loading for such a class (of course, this is just a quick example):

class ArticleService 
{
    public function __construct(
        public Carbon $createdAt,
        public Carbon $publishedAt,
        // ...
    ) {
    }
}

@DarkGhostHunter could you explain where the claim that "The main problem I have with Lazy objects is testing" comes from? :D I don’t see any issue with testing - a Lazy Object gets initialized upon interaction and behaves like a regular object. You can see that in the tests in this PR - take a look, there’s an assertInstanceOf on the lazy object. If I misunderstood the question, please clarify or provide an example - I’ll try to test it and share some feedback ;)

@DarkGhostHunter
Copy link
Contributor

@DarkGhostHunter could you explain where the claim that "The main problem I have with Lazy objects is testing" comes from? :D I don’t see any issue with testing - a Lazy Object gets initialized upon interaction and behaves like a regular object. You can see that in the tests in this PR - take a look, there’s an assertInstanceOf on the lazy object. If I misunderstood the question, please clarify or provide an example - I’ll try to test it and share some feedback ;)

Nevermind, I though they weren't testeable. Keep up the good work.

@rodrigopedra
Copy link
Contributor

Let’s be honest - Carbon is a heavy class and sometimes it can even freeze the IDE.

Never heard of this problem before, to be honest.

I'm also considering a case where we have a class overloaded with dependencies, such as Carbon. [...] I’m referring to lazy loading for such a class

I get the idea. What I don't get is the benefits compared to what the container already does.

It already runs a factory function only when needed. And you can use method injection, or the app() helper, to narrow down a dependency's scope.

But maybe I am missing something. Let's wait on the maintainers.

@antonkomarev
Copy link
Contributor

antonkomarev commented May 5, 2025

I don't feel a need for this feature, but maybe it will be better to define it in the constructor of the class where dependency is required (for example in the controller) and not in dependency class? Otherwise you can't define it in third party classes.

@taylorotwell
Copy link
Member

taylorotwell commented May 7, 2025

Can you elaborate a bit more about why we couldn't support it at the parameter level?

@taylorotwell taylorotwell marked this pull request as draft May 10, 2025 18:24
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment
Labels
None yet
Projects
None yet
Development

Successfully merging this pull request may close these issues.

9 participants