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 () => 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 helper allows 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()]);

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(...));

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
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.

2 participants