Skip to content

Conversation

@timacdonald
Copy link
Member

@timacdonald timacdonald commented Nov 19, 2025

PHP's new lazy objects are very cool and useful for keeping the memory footprint low and improving performance in some use cases.

Unfortunately they are also super clunky to use.

Example of using the native API to create a ghost and a proxy:

<?php

use Illuminate\Support\Facades\Http;
use ReflectionClass;
use Vendor\Facades\ResultFactory;
use Vendor\Result;

$response = Http::post(...);

$instance = (new ReflectionClass(Result::class))->newLazyGhost(fn (Result $instance) => $instance->__construct($response->json()));
$instance = (new ReflectionClass(Result::class))->newLazyProxy(fn (Result $proxy) => ResultFactory::make($response->json()));

Clunky?

Yes. Clunky.

  1. Rather verbose (and we haven't even tried eagerly setting properties yet)
  2. Have to reach for reflection. Feels heavy.
  3. The subtle different between calling __construct when creating a ghost vs returning a new instance when creating a proxy.
  4. In general, having the call $object->__construct feels nasty.
  5. When creating a proxy, I have to receive the proxy object but I never want to do anything with it.

This PR introduces two new support helpers, lazy and proxy, in hopes to make using these features more ergonomic.

Re-worked example using the proposed helpers:

<?php

use Illuminate\Support\Facades\Http;
use Vendor\Facades\ResultFactory;
use Vendor\Result;
use function Illuminate\Support\lazy;
use function Illuminate\Support\proxy;

$response = Http::get(...);

$instance = lazy(Result::class, fn () => [$response->json()]);
$instance = proxy(Result::class, fn (): Result => ResultFactory::make($response->json()));

You may be thinking Hey, this is an older style Laravel API. We should be able to pass a single closure through like so:

<?php

$instance = lazy(fn (Result $instance) => [$response->json()]);
$instance = proxy(fn (Result $proxy) => ResultFactory::make($response->json()));

If that is you, please hold back your feelings until the end. For now, I'll stick with the lazy($class, $callback) API.

Eagerly setting properties

Ghost objects allow you to eagerly set properties. Using the native APIs we would need the following:

<?php

use Illuminate\Support\Facades\Http;
use ReflectionClass;
use Vendor\Facades\ResultFactory;
use Vendor\Result;

$response = Http::post(...);

$reflectionClass = new ReflectionClass(Result::class);
$instance = $reflectionClass->newLazyGhost(fn (Result $instance) => $instance->__construct($response->json()));
$reflectionClass->getProperty('createdAt')->setRawValueWithoutLazyInitialization($instance, now());

The lazy and proxy helper allow you to pass eager values through as a named parameter using a hash map:

<?php

use Illuminate\Support\Facades\Http;
use ReflectionClass;
use Vendor\Facades\ResultFactory;
use Vendor\Result;

$response = Http::post(...);

$instance = lazy(Result::class, fn () => [$response->json()], eager: ['createdAt' => now()]);

Proxy quirks

The way that lazy and proxy objects work regarding eager properties is slightly different. Lazy objects persist their eager properties after they are resolved / interacted with, however proxy objects do not.

Naming

I've opted for lazy over ghost as a function name, even though PHP calls them ghosts.

  • To me, ghost makes me feel like I'm gonna have to look up the difference between a ghost and a proxy every time I reach for them. lazy vs proxy feels more clearer to me.
  • We already use lazy in the framework for similar things.

Full API examples

Lazy

lazy is the go to when you are in full control of creating the object, e.g., if you would otherwise call new Result yourself, whether the class is in your namespace or a vendor namespace.

<?php

namespace App;

use App\Service\Result;
use function Illuminate\Support\lazy;

class Result
{
    public function __construct(
        public $param1,
        public $param2,
    ) {
        //
    }
}

/*
 * Provide a list of args for the constructor...
 */

$instance = lazy(Result::class, fn () => [$arg1, $arg2]);

/*
 * Provide the arguments using named parameters...
 */

$instance = lazy(Result::class, fn () => [
    'param2' => $arg2,
    'param1' => $arg1,
]);

/*
 * As an escape hatch, the closure does receive the lazy
 * object so you can call the constructor manually or other set up functions...
 */

$instance = lazy(Result::class, function ($instance) {
    $instance->__construct($arg1, $arg2);

    $instance->moreSetup();
});


/*
 * NOTE when passing an array as the first argument, you need to wrap it in an array...
 */
$arg1 = [];
$instance = lazy(Result::class, fn () => [$arg1]);
// or...
$instance = lazy(Result::class, fn () => [[]]);

Even with the more verbose escape hatch, it feels much more ergonomic to me. A comparison:

$instance = (new ReflectionClass(Result::class))->newLazyGhost(fn (Result $instance) => $instance->__construct($response->json()));

$instance = lazy(Result::class, fn () => [$response->json()]);

Proxy

proxy is what you would use for if you are not in control of instantiating the object and a 3rd party is in charge of creating the object. You can make make their instantiation logic lazy:

<?php

namespace App;

use Vendor\Facades\Service;
use Vendor\Result;
use function Illuminate\Support\proxy;

/*
 * Provide a list of args for the constructor...
 */

$instance = proxy(Result::class, Service::get(...));

// return type driven

$instance = proxy(fn (): Result => ResultFactory::make($response->json()));

Supplemental APIs

In similar APIs, Laravel has opted for determining the type from the closure. Although this is possible, I propose that we offer this as a secondary API, rather than the primarily documented API. I think it is fair to assume some people will attempt this API, based on previous Laravel experience, so it makes sense to support it. I'll outline below why I don't think it is a great primary API, though.

<?php

- $instance = lazy(Result::class, fn () => [$response->json()]);
+ $instance = lazy(fn (Result $instance) => [$response->json()]);

- $instance = proxy(Result::class, fn () => ResultFactory::make($response->json()));
+ $instance = proxy(fn (Result $proxy) => ResultFactory::make($response->json()));

Given that majority of use-cases, in my opinion, are going to return an array of arguments, being forced to pass through the object as a variable to then never use does not feel nice:

<?php

$instance = lazy(fn (Result $instance) => [$response->json()]);
$instance = proxy(fn (Result $proxy) => ResultFactory::make($response->json()));

Notice we never reference $instance or $proxy variables in the closure. Sure, we could do take a leaf out of the JS book and use $_ but it still feels off to me:

<?php

$instance = lazy(fn (Result $_) => [$response->json()]);
$instance = proxy(fn (Result $_) => ResultFactory::make($response->json()));

I love other APIs that do this, but in all of those cases I want the thing I'm accepting. That isn't really the case with these helpers.

Comparing side-by-side for a vibes check:

<?php

$instance = lazy(fn (Result $_) => [$response->json()]);
$instance = lazy(Result::class, fn () => [$response->json()]);

The only time this particular API is an improvement on the suggested primary API is for the lazy helper when you need to call additional APIs when constructing, which also feels like a usage outlier:

<?php

$instance = lazy(function (Result $instance) {
    $instance->__construct($arg1, $arg2);

    $instance->moreSetup();
});

What if I only have a single argument to pass?

I went back and forth on this a bit, e.g., offering the ability to accept a single argument rather than an array:

- $instance = lazy(Result::class, fn () => [$response->body()]);
+ $instance = lazy(Result::class, fn () => $response->body());

If feel this adds ambiguity. What if I want to accept a single argument that is an array? You still need to wrap it in an array. Because of this, I felt keeping it simple and requiring a wrapping array was the best approach.

@github-actions
Copy link

Thanks for submitting a PR!

Note that draft PR's are not reviewed. If you would like a review, please mark your pull request as ready for review in the GitHub user interface.

Pull requests that are abandoned in draft may be closed due to inactivity.

@timacdonald timacdonald force-pushed the lazy branch 3 times, most recently from 8bd4c08 to 962e739 Compare November 19, 2025 22:05
@timacdonald timacdonald changed the title Add lazy and proxy helpers Introduce lazy object and proxy object support helpers Nov 19, 2025
@timacdonald timacdonald force-pushed the lazy branch 3 times, most recently from 8dba754 to 36601d2 Compare November 20, 2025 22:23
@timacdonald timacdonald marked this pull request as ready for review November 23, 2025 21:47
@WendellAdriel
Copy link
Contributor

Amazing work, @timacdonald
Just a QQ, for the case where an additional setup is needed, instead of calling the constructor manually, what if we could pass an optional parameter that would be called automatically. This way we wouldn't need to call the constructor manually. We would pass the array of parameters and the closure that should run right after the constructor is called.

@timacdonald
Copy link
Member Author

I hear that. I'm not sure at that point it might make the API / function usage feel like it has too much going on.

I liked the simplicity of the current API with an escape hatch for more complex work as needed.

No super strong feelings either way, though.

@WendellAdriel
Copy link
Contributor

I like the current API as well. Was just a thought that might work to just remove the need of manually calling the constructor. However, as you mentioned it will be one more thing for the helper to deal with, so I'm not sure if it's something you want to add.

Comment on lines +207 to +208
* @param (\Closure(TValue): mixed)|int $callback
* @param int $options
Copy link
Contributor

Choose a reason for hiding this comment

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

Please don't use a random int:

Suggested change
* @param (\Closure(TValue): mixed)|int $callback
* @param int $options
* @param (\Closure(TValue): mixed)|0|\ReflectionClass::SKIP_INITIALIZATION_ON_SERIALIZE $callback
* @param 0|\ReflectionClass::SKIP_INITIALIZATION_ON_SERIALIZE $options

Copy link
Member Author

Choose a reason for hiding this comment

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

I'd personally prefer to keep this as int so we don't need to update it every time a new option is added.

It would also mean that we could have issues if different PHP versions introduce different options that aren't supported by PHP versions the framework still supports.

I'd rather stick to what PHP itself uses.

Copy link
Contributor

Choose a reason for hiding this comment

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

Then at least do ReflectionClass::*, which is not entirely correct, but still a lot more narrow than the wide open signed int

Copy link
Member Author

Choose a reason for hiding this comment

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

Will leave that decision for Taylor.

* @param array<string, mixed> $eager
* @return TValue
*/
function lazy($class, $callback = 0, $options = 0, $eager = [])
Copy link
Contributor

Choose a reason for hiding this comment

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

What if both $callback and $options are int?

lazy(MyClass::class); // $callback and $options both default to 0
lazy(MyClass::class, options: 42); // It will fail, because 0 cannot be called and newLazyGhost() does not accept 42 (only 0 and 8)

Copy link
Member Author

Choose a reason for hiding this comment

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

Don't hold it that way. Use the (yet to be) documented API.

It failing is a feature, not a bug.

Copy link
Contributor

Choose a reason for hiding this comment

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

Yeah, but it would be better if it was explicitly validated so there could be a descriptive exception message. This can happen easily as the method signature doesn't clearly describe the options without consulting the docs.

Copy link
Member Author

Choose a reason for hiding this comment

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

I don't think we generally do this with other overloaded APIs. Marked as unresolved and will let Taylor chime in here, if he wants.

@timacdonald timacdonald marked this pull request as draft November 25, 2025 23:35
@rodrigopedra
Copy link
Contributor

@timacdonald great work on this =)

I was thinking about the helpers guards using the version_compare() calls.

No other helpers have those guards.

If a code base calls those helpers, they will fail with an undefined function error on an unsupported PHP version.

If we remove the version_compare() call from the guard, and a code base call those helpers from on an unsupported PHP version, then there will be an exception for the unsupported features.

My point is, are those version_compare() calls really necessary? As an unsupported PHP version will error either way?

On every spot I could find of a version_compare() call in the code base, they are used to switch between code paths, not to implicitly trigger an error or to define a feature just available to a certain version.

@timacdonald
Copy link
Member Author

Good point. Removed them

@timacdonald timacdonald marked this pull request as ready for review November 27, 2025 01:56
@taylorotwell taylorotwell merged commit d4d36e8 into laravel:12.x Nov 28, 2025
106 of 128 checks passed
@timacdonald timacdonald deleted the lazy branch November 30, 2025 23:16
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.

6 participants