dominik

joined 3 weeks ago
MODERATOR OF
[–] [email protected] 1 points 2 days ago* (last edited 2 days ago)

You mean the blog post I wrote myself and published on my ActivityPub enabled blog? That kind of "spam bot"?

[–] [email protected] 1 points 3 days ago

Heh, I've been toying with that idea ever since I learned that /nix was added to the exclusions, last month I finally had the time to test it out! Hopefully your Steam Deck arrives soon, it's a truly amazing device.

 

Immutable systems offer many benefits—until you need to customize your filesystem by installing packages. While installing software isn’t difficult per se, SteamOS’s design means that most customizations are wiped during system upgrades. About a year ago, Valve added /nix to the list of directories that remain intact during updates, and that’s where Nix stores all of its packages.

If you’re not familiar with Nix: it’s a package manager that uses declarative definitions for your software instead of commands like apt install or dnf install. You simply list all your desired packages in a configuration file, and Nix takes care of installing them. Additionally, the handy nix-shell utility lets you spawn temporary shells with the packages you specify.

There are two primary ways to work with Nix comfortably: you can either run NixOS (which isn’t ideal on a Steam Deck) or use Home Manager.

Installing Nix

Switch to Desktop Mode and open Konsole for the following steps. First, install Nix itself using this command (see the official installation instructions):

sh <(curl -L https://nixos.org/nix/install) --no-daemon

This command installs Nix in single-user mode (--no-daemon), which is a good fit for SteamOS since it may not require sudo for most operations. (If it does ask for sudo, you’ll need to set up sudo on your Steam Deck.)

Next, load Nix into your current terminal session:

source .bash_profile

By default, Nix uses the unstable branch of packages. To switch to the stable channel, run:

nix-channel --add https://nixos.org/channels/nixos-24.11 nixpkgs

This command sets your nixpkgs channel to the latest stable version (in this example, 24.11). In the future, check the current stable version on the NixOS homepage.

Nix is now installed—but without Home Manager, it isn’t very user-friendly.

Installing Home Manager

First, add the Home Manager channel to your Nix configuration:

nix-channel --add https://github.com/nix-community/home-manager/archive/release-24.11.tar.gz home-manager

Note: Ensure that the version for both Nix and Home Manager match. In this example, both are 24.11.

If you prefer the unstable branch, you can instead run: nix-channel --add https://github.com/nix-community/home-manager/archive/master.tar.gz home-manager

Update your channels to include these changes:

nix-channel --update

Before proceeding, back up your Bash configuration files:

  • mv .bash_profile .bash_profile.bckp
  • mv .bashrc .bashrc.bckp

If you choose not to back them up, you’ll need to remove them because Home Manager creates these files during installation and will fail if they already exist.

Now, run the Home Manager installation:

nix-shell '<home-manager>' -A install

Once the installation completes, create your Home Manager configuration file using a text editor:

kate ~/.config/home-manager/home.nix

Paste in the following configuration:

{ config, pkgs, ... }:
{
  home.username = "deck";
  home.homeDirectory = "/home/deck";

  programs.bash = {
    enable = true;
    initExtra = ''
      if [ -e $HOME/.nix-profile/etc/profile.d/nix.sh ]; then . $HOME/.nix-profile/etc/profile.d/nix.sh; fi

      export NIX_SHELL_PRESERVE_PROMPT=1
      if [[ -n "$IN_NIX_SHELL" ]]; then
        export PS1="$PS1(nix-shell) "
      fi
    '';
  };

  home.stateVersion = "24.11"; # don't change this even if you upgrade your channel in the future, this should stay the same as the version you first installed nix on

  home.packages = with pkgs; [

  ];

  programs.home-manager.enable = true;
}

This configuration does the following:

  • Sets your username to deck (the default on Steam Deck).
  • Specifies the correct path to your home directory.
  • Enables Home Manager to manage your Bash shell and ensures the Nix environment is loaded automatically—so you won’t have to source it manually each time.
  • Adds a (nix-shell) suffix to your terminal prompt when you’re in a Nix shell, which is a subtle but useful improvement over the default behavior.
  • Defines the home.stateVersion, which should remain the same as when you first installed Nix (even if you later change your channels). You should never change it after the initial Nix installation
  • Enables Home Manager itself.
  • Provides an empty list (home.packages) where you can later add your desired packages.

Apply your new configuration by running:

home-manager switch

This is the basic workflow for managing your environment with Nix: update your configuration file and then run home-manager switch to apply the changes.

After closing and reopening your terminal, test the setup by running nix-shell. If you see an error indicating that default.nix is missing, everything is working as expected. (If the command isn’t found at all, something went wrong.)

Installing packages

To install packages, simply add them to the home.packages list in your configuration file. For example, to install nmap (for network scanning) and cowsay (because a cow makes everything better), update your configuration as follows:

  home.packages = with pkgs; [
      nmap
      cowsay
  ];

Keep the rest of the file unchanged, then apply the new configuration with home-manager switch. You can test the setup by running:

echo "Hello from my Steam Deck!" | cowsay

You should see this beauty in your terminal:

 ___________________________
< Hello from my Steam Deck! >
 ---------------------------
        \   ^__^
         \  (oo)\_______
            (__)\       )\/\
                ||----w |
                ||     ||

Running nmap should display its usage instructions. If you decide to remove nmap (you're keeping cowsay, right?), just delete it from the configuration file and run home-manager switch again.

Tips

  • Create a desktop shortcut to your configuration file:

    • ln -s ~/.config/home-manager/home.nix ~/Desktop/Nix_Config
  • Run nix-collect-garbage periodically to remove unused packages and free up space.

  • Install the comma package. This nifty tool lets you run any package on the fly by simply prefixing the command with a comma.

    • For example, instead of adding nmap to your configuration, you could run , nmap to temporarily use it. (notice the comma in front of nmap)
  • Nix can do much more than just manage packages—for instance, you can use it to create environment variables, shell aliases, systemd services, files, and more.

Cover image sources: Wikimedia Commons, NixOS

 

Lazy objects allow you to delay initialization until it’s absolutely necessary. This is particularly useful when an object depends on I/O operations—such as accessing a database or making an external HTTP request. Although you could previously implement lazy loading in userland, there were significant caveats. For example, you couldn’t declare the proxied class as final, because the lazy proxy must extend it to satisfy type checks. If you’ve ever used Doctrine, you might have noticed that entities cannot be declared final for precisely this reason.

Without further ado, let's dive right in!

Lazy deserializer

For this project, I created a simple DTO:

final readonly class Product
{
    public function __construct(
        public string $name,
        public string $description,
        public float $price,
    ) {
    }
}

Notice that the class is declared as both final and readonly—something that wouldn’t have been possible with a pure userland implementation. Here’s what the deserializer looks like:

final readonly class LazyDeserializer
{
    /**
     * @template T of object
     * @param class-string<T> $class
     * @return T
     */
    public function deserialize(array $data, string $class): object
    {
        // todo
    }
}

This setup lets us write code like the following:

$data = [
    'name' => 'Door knob',
    'description' => "The coolest door knob you've ever seen!",
    'price' => 123.45,
];

$deserializer = new LazyDeserializer();
$object = $deserializer->deserialize($data, Product::class);

var_dump($object);

Implementing the deserializer

I split the implementation into multiple methods for better maintainability. Let’s start with the single public method whose signature we just saw:

    /**
     * @template T of object
     * @param class-string<T> $class
     * @return T
     */
    public function deserialize(array $data, string $class): object
    {
        $reflection = new ReflectionClass($class);

        return $reflection->newLazyGhost(function (object $object) use ($data): void {
            $this->deserializeObject($data, $object);
        });
    }

First, we obtain a reflection of the target class and then call its newLazyGhost method. The lazy ghost is responsible for creating the lazily initialized object. It accepts a single callback that receives an instance of the target object (which remains uninitialized) and uses it to set up the properties in the deserializeObject method.

At this point, the method returns an object of the target class (specified by the $class parameter) with all its properties uninitialized. These properties will be initialized only when you access them. For example, if you var_dump the resulting object right now, you might see something like:

lazy ghost object(App\Dto\Product)#7 (0) {
  ["name"]=>
  uninitialized(string)
  ["description"]=>
  uninitialized(string)
  ["price"]=>
  uninitialized(float)
}

Notice that it doesn’t matter that the private deserializeObject method isn’t implemented yet—the object remains truly lazy. Any errors related to initialization will only appear when you try to access one of its uninitialized properties.

Here's an implementation of the private method:

    private function deserializeObject(array $data, object $object): void
    {
        $reflection = new ReflectionObject($object);

        foreach ($reflection->getProperties(ReflectionProperty::IS_PUBLIC) as $property) {
            if (!isset($data[$property->getName()])) {
                if ($property->getType()?->allowsNull()) {
                    $property->setValue($object, null);
                }
                continue;
            }

            $property->setValue($object, $data[$property->getName()]);
            unset($data[$property->getName()]);
        }

        if (count($data)) {
            throw new LogicException('There are left-over data in the array which could not be deserialized into any property.');
        }
    }

I’m using reflection here because the object is marked as readonly—this is the only way to set a readonly property outside the constructor. If the properties weren’t readonly, you could simply assign values directly (e.g. $object->$propertyName = $value).

The process is straightforward: we iterate over each public property of the class, assign the corresponding value from the data array, and if a property is missing (and its type allows null), we set it to null. Finally, we ensure there’s no leftover data, which would indicate a mismatch between the data and the model. (Note that this is a naive implementation; real-world deserializers tend to be more robust.)

Now, let’s modify the previous example slightly to trigger the initialization of the model:

$data = [
    'name' => 'Door knob',
    'description' => "The coolest door knob you've ever seen!",
    'price' => 123.45,
];

$deserializer = new LazyDeserializer();
$object = $deserializer->deserialize($data, Product::class);

var_dump($object); // this will print the uninitialized model

$object->name; // simply calling a property will force the object to initialize

var_dump($object); // this now prints:

// object(App\Dto\Product)#7 (3) {
//  ["name"]=>
//  string(9) "Door knob"
//  ["description"]=>
//  string(39) "The coolest door knob you've ever seen!"
//  ["price"]=>
//  float(123.45)
//}

Note that this implementation isn’t very useful on its own since it merely assigns properties from a static array—there’s no I/O involved. Let’s enhance it to support deserializing more complex values, such as enums, nested objects, and (most importantly) I/O-bound entities (which we’ll simulate with an HTTP request). First, instead of directly assigning the value, I add another private method:

$property->setValue($object, $this->assignValue($property, $data[$property->getName()]));

Now, let’s implement assignValue:

    private function assignValue(ReflectionProperty $property, mixed $value): mixed
    {
        $type = $property->getType();
        if (!$type) {
            return $value;
        }
        if ($value === null && $type->allowsNull()) {
            return null;
        }
        if (!$type instanceof ReflectionNamedType) {
            throw new LogicException('Only a single type is allowed');
        }

        $typeName = $type->getName();
        if (is_a($typeName, BackedEnum::class, true)) {
            return $typeName::from($value);
        } else if (is_array($value) && class_exists($typeName)) {
            return $this->deserialize($value, $typeName);
        } else if ($this->isHttpEntity($typeName) && is_string($value)) {
            return $this->fetchHttpEntity($typeName, $value);
        }

        return $value;
    }

Here’s what happens in assignValue:

  • If the property has no type, the value is returned as is.
  • If the value is null and the type is nullable, null is returned.
  • An exception is thrown if the type isn’t a single named type (supporting multiple types would add too much complexity for this example).
  • Three cases are then handled:
    • If the type is a backed enum, we convert the value using its built-in from method.
    • If the value is an array and the type corresponds to an existing class, we recursively call deserialize to support nested objects.
    • If the type is marked as a HTTP entity (using the HttpEntity attribute) and the value is a string, we assume it represents an ID and fetch the entity.

Here are some more objects that the deserializer now supports:

enum Availability: int
{
    case InStock = 1;
    case OnTheWay = 2;
    case OutOfStock = 3;
}

final readonly class ProductVariant
{
    public function __construct(
        public string $color,
        public string $size,
    ) {
    }
}

#[HttpEntity]
final readonly class Seller
{
    public function __construct(
        public string $id,
        public string $name,
        public float $rating,
    ) {
    }
}

For completeness, here’s the definition of the HttpEntity attribute and a helper method to check for it:

#[Attribute(Attribute::TARGET_CLASS)]
final readonly class HttpEntity
{
}

private function isHttpEntity(string $typeName): bool
{
    if (!class_exists($typeName)) {
        return false;
    }

    $reflection = new ReflectionClass($typeName);
    $attributes = $reflection->getAttributes(HttpEntity::class);

    return count($attributes) > 0;
}

The enum and the non-HTTP entity class work out of the box. For example:

final readonly class Product
{
    public function __construct(
        public string $name,
        public string $description,
        public float $price,
        public Availability $availability,
        public ?ProductVariant $variant = null,
    ) {
    }
}

$data = [
    'name' => 'Door knob',
    'description' => "The coolest door knob you've ever seen!",
    'price' => 123.45,
    'availability' => 2,
    'variant' => [
        'color' => 'golden',
        'size' => '3',
    ],
];

$deserializer = new LazyDeserializer();
$object = $deserializer->deserialize($data, Product::class);

var_dump($object);

// lazy ghost object(App\Dto\Product)#7 (0) {
//  ["name"]=>
//  uninitialized(string)
//  ["description"]=>
//  uninitialized(string)
//  ["price"]=>
//  uninitialized(float)
//  ["availability"]=>
//  uninitialized(App\Enum\Availability)
//  ["variant"]=>
//  uninitialized(?App\Dto\ProductVariant)
//}

$object->name;

var_dump($object);

// object(App\Dto\Product)#7 (5) {
//  ["name"]=>
//  string(9) "Door knob"
//  ["description"]=>
//  string(39) "The coolest door knob you've ever seen!"
//  ["price"]=>
//  float(123.45)
//  ["availability"]=>
//  enum(App\Enum\Availability::OnTheWay)
//  ["variant"]=>
//  lazy ghost object(App\Dto\ProductVariant)#19 (0) {
//    ["color"]=>
//    uninitialized(string)
//    ["size"]=>
//    uninitialized(string)
//  }
//}

$object->variant->color;

// object(App\Dto\Product)#7 (5) {
//  ["name"]=>
//  string(9) "Door knob"
//  ["description"]=>
//  string(39) "The coolest door knob you've ever seen!"
//  ["price"]=>
//  float(123.45)
//  ["availability"]=>
//  enum(App\Enum\Availability::OnTheWay)
//  ["variant"]=>
//  object(App\Dto\ProductVariant)#19 (2) {
//    ["color"]=>
//    string(6) "golden"
//    ["size"]=>
//    string(1) "3"
//  }
//}

Notice that the variant property is also lazily initialized—which is pretty neat. Every nested object is handled lazily.

I/O bound entities

Now, let’s move on to HTTP entities. We’ll create a service that “fetches” them (in this case, we’ll simulate the fetch):

final readonly class HttpEntityFetcher
{
    public function fetchRawByIdAndType(string $id, string $type): ?array
    {
        sleep(1);
        return [
            'id' => $id,
            'name' => 'Cool seller',
            'rating' => 4.9,
        ];
    }
}

Here, I simulate a slow HTTP request that takes one second to complete and returns JSON data (already decoded into an array). Note that for this example the fetch always returns a seller.

Now all that’s missing is the LazyDeserializer::fetchHttpEntity() method:

public function __construct(
    private HttpEntityFetcher $entityFetcher,
) {
}

/**
 * @template T of object
 *
 * @param class-string<T> $typeName
 * @return T|null
 */
private function fetchHttpEntity(string $typeName, string $id): ?object
{
    return new ReflectionClass($typeName)->newLazyGhost(function (object $object) use ($typeName, $id): void {
        $data = $this->entityFetcher->fetchRawByIdAndType($id, $object::class);
        if (!is_array($data)) {
            throw new InvalidArgumentException('An object of type ' . $typeName . ' with id ' . $id . ' could not be fetched.');
        }

        $this->deserializeObject($data, $object);
    });
}

This lazy ghost postpones the HTTP request until one of the object’s properties is actually accessed. Next, let’s add the seller property to our product:

final readonly class Product
{
    public function __construct(
        public string $name,
        public string $description,
        public float $price,
        public Availability $availability,
        public Seller $seller,
        public ?ProductVariant $variant = null,
    ) {
    }
}

And here’s an example that adds some timing measurements to our deserialization:

$data = [
    'name' => 'Door knob',
    'description' => "The coolest door knob you've ever seen!",
    'price' => 123.45,
    'availability' => 2,
    'variant' => [
        'color' => 'golden',
        'size' => '3',
    ],
    'seller' => 'some-seller-id',
];

$deserializer = new LazyDeserializer(new HttpEntityFetcher());
$start = microtime(true);
$object = $deserializer->deserialize($data, Product::class);
$end = microtime(true);

echo "Deserializer took: " . number_format($end - $start, 10) . " seconds", PHP_EOL;

$start = microtime(true);
$object->seller->name;
$end = microtime(true);

echo "Fetching seller id took: " . number_format($end - $start, 10) . " seconds", PHP_EOL;

On my PC, this prints:

Deserializer took: 0.0000250340 seconds
Fetching seller name took: 1.0002360344 seconds

The deserialization is nearly instantaneous—the delay comes when the HTTP request is eventually executed during initialization.

Partially initializing ghost objects

In the example above, there’s one piece of information we already know about the seller even before any HTTP request is made: its ID. Triggering a network call just to obtain the ID is unnecessary. Fortunately, we can initialize that property immediately:

/**
 * @template T of object
 *
 * @param class-string<T> $typeName
 * @return T|null
 */
private function fetchHttpEntity(string $typeName, string $id): ?object
{
    $reflection = new ReflectionClass($typeName);
    $entity = $reflection->newLazyGhost(function (object $object) use ($typeName, $id): void {
        $data = $this->entityFetcher->fetchRawByIdAndType($id, $object::class);
        if (!is_array($data)) {
            throw new InvalidArgumentException('An object of type ' . $typeName . ' with id ' . $id . ' could not be fetched.');
        }

        unset($data['id']);
        $this->deserializeObject($data, $object);
    });
    $reflection->getProperty('id')->setRawValueWithoutLazyInitialization($entity, $id);

    return $entity;
}

The setRawValueWithoutLazyInitialization method (a catchy name, right?) lets you assign a value to a property without forcing the rest of the object to be initialized.

$start = microtime(true);
$object = $deserializer->deserialize($data, Product::class);
$end = microtime(true);

echo "Deserializer took: " . number_format($end - $start, 10) . " seconds", PHP_EOL;
var_dump($object->seller);

$start = microtime(true);
$object->seller->id;
$end = microtime(true);

echo "Fetching seller id took: " . number_format($end - $start, 10) . " seconds", PHP_EOL;
var_dump($object->seller);

$start = microtime(true);
$object->seller->name;
$end = microtime(true);

echo "Fetching seller name took: " . number_format($end - $start, 10) . " seconds", PHP_EOL;
var_dump($object->seller);

This prints timings similar to:

Deserializer took: 0.0000338554 seconds
Fetching seller id took: 0.0000009537 seconds
Fetching seller name took: 1.0001599789 seconds

As you can see, accessing the ID is immediate, while accessing another property (like the name) triggers the full initialization.

lazy ghost object(App\Entity\Seller)#20 (1) {
  ["id"]=>
  string(14) "some-seller-id"
  ["name"]=>
  uninitialized(string)
  ["rating"]=>
  uninitialized(float)
}

lazy ghost object(App\Entity\Seller)#20 (1) {
  ["id"]=>
  string(14) "some-seller-id"
  ["name"]=>
  uninitialized(string)
  ["rating"]=>
  uninitialized(float)
}

object(App\Entity\Seller)#20 (3) {
  ["id"]=>
  string(14) "some-seller-id"
  ["name"]=>
  string(11) "Cool seller"
  ["rating"]=>
  float(4.9)
}

That’s it for the deserializer example! It’s a simplified implementation, but I imagine that Doctrine may eventually replace its userland proxy approach with these core lazy objects once they target PHP 8.4 and later.

Private key generating example

As a bonus, here’s an additional example—a private key generator that I’ve actually used in one of my libraries. (View on GitHub)

public function generate(int $bits = 4096): KeyPair
{
    $reflection = new ReflectionClass(KeyPair::class);
    $keyPair = $reflection->newLazyGhost(function (KeyPair $keyPair) use ($bits) {
        $config = [
            'private_key_type' => OPENSSL_KEYTYPE_RSA,
            'private_key_bits' => $bits,
        ];
        $resource = openssl_pkey_new($config) ?: throw new CryptographyException('Failed generating new private key');

        $privateKeyPem = '';
        openssl_pkey_export($resource, $privateKeyPem);
        assert(is_string($privateKeyPem));

        $details = openssl_pkey_get_details($resource) ?: throw new CryptographyException('Failed decoding the private key');
        $publicKeyPem = $details['key'];
        assert(is_string($publicKeyPem));

        $reflection = new ReflectionObject($keyPair);
        $reflection->getProperty('privateKey')->setValue($privateKeyPem);
        $reflection->getProperty('publicKey')->setValue($publicKeyPem);

        return $keyPair;
    });
    assert($keyPair instanceof KeyPair);

    return $keyPair;
}

This postpones the expensive operation (generating a 4096 bits private key) until it's actually needed.

 

The problem

When you have a <ng-template> that accepts parameters via context, you usually lose TypeScript's type safety, reverting to the prehistoric age of JavaScript with no type enforcement:

<ng-template #someTemplate let-someVariable="someVariable">
  {{Math.abs(someVariable)}} <!-- compiler and IDE have no idea that the variable is a string -->
</ng-template>

With this approach, you can perform any operation on someVariable, and the compiler won't warn you—even if it results in runtime errors.

The solution

To ensure type safety, we can create a type assertion guard directive:

@Directive({
  selector: 'ng-template[some-template]',
  standalone: true,
})
export class SomeTemplateNgTemplate {
  static ngTemplateContextGuard(
    directive: SomeTemplateNgTemplate,
    context: unknown
  ): context is {someVariable: string} {
    return true;
  }
}

Explanation

  1. Directive setup

    • This directive applies to <ng-template> elements that include the some-template attribute (ng-template[some-template] in the selector).
    • It's marked as standalone, which is the recommended approach in modern Angular.
  2. Type Context Guard

    • The class name is not important and can be anything.

    • The static ngTemplateContextGuard function is where the magic happens.

    • It must accept two parameters:

      • An instance of itself (directive: SomeTemplateNgTemplate).
      • The context (which is typed as unknown which is a more type-safe any).
    • The return type uses a TypeScript type predicate, which tells the compiler: If this function returns true, then the context must match the given type { someVariable: string }.

Since this function always returns true, TypeScript will assume that every template using this directive has the expected type.

Important note: As with all TypeScript type assertions, this is a compile-time safety measure—it does not enforce types at runtime. You can still pass invalid values, but TypeScript will warn you beforehand.

Applying the Directive

Now, update your template to use the directive:

<ng-template some-template #someTemplate let-someVariable="someVariable">
  {{Math.abs(someVariable)}}
</ng-template>

The result

With the some-template directive in place, Angular now correctly infers the type of someVariable. If you try to use Math.abs(someVariable), TypeScript will now show an error:

NG5: Argument of type 'string' is not assignable to parameter of type 'number'.

Conclusion

By leveraging ngTemplateContextGuard, you can enforce strong typing within ng-template contexts, making your Angular code safer and more maintainable. This simple trick helps catch potential errors at compile time rather than at runtime—ensuring better developer experience and fewer unexpected bugs.

 

If you're unsure where you could (or why you should) use feature flags in your project, this section is for you, otherwise feel free to skip this part.

What are feature flags

Feature flags are runtime switches that enable or disable specific code paths dynamically. You might already be using them without realizing it! If your system allows enabling or disabling functionality via database settings (e.g., toggling registrations, comments, or user uploads), you're already using a basic form of feature flags. But these self-built options are rarely as thought-out as dedicated feature flagging systems.

Dedicated feature flagging systems

Dedicated feature flagging systems provide a standardized way to manage feature toggles and unlock additional use cases, such as:

  • Gradually roll out features to a subset of users, such as internal users or beta testers.

    • Makes it possible to do a gradual rollout to test out the reactions without deploying a feature to everyone
    • Enable features based on the region of the user (like GDPR, CCPA)
  • Create experimental features without maintaining separate branches

  • A/B test multiple versions of a new feature

  • Implement a kill switch to turn off some parts of the code in case of emergency (attack, data corruption...)

  • Replace your built-in permission system

  • Create toggleable features that are only needed in certain cases (for example, enable a high verbosity logging if you run into issues)

  • Rollback features if they're broken

  • and many more

Unleash

Disclaimer: I originally wrote the open-source Unleash PHP SDK, which was later adopted as the official Unleash SDK. While I’m paid to maintain it, this article is not sponsored (and I'm not an employee of Unleash). I’m writing it for the same reasons I originally created the SDK: I love how Unleash is implemented and think more people should use it!

Unleash is one such system. Unleash offers both a paid plan and a self-hosted open-source version. While the open-source version lacks some premium features, since the release of the constraints feature to the OSS version it's feature-complete for my needs.

What makes Unleash unique is the way the feature evaluation is handled: everything happens locally, meaning your app does not leak any data to Unleash. Your application also avoids performance overhead from unnecessary HTTP requests. Usually these systems do the evaluation on the server and just return a yes/no response. With Unleash, you instead get the whole configuration as a simple JSON and the SDK does evaluation locally (to the point that you could even use the SDK without Unleash at all, you can simply provide a static JSON). Furthermore, the features are cached locally for half a minute or so, thus the only I/O overhead Unleash adds is 2 http requests a minute. And another cool feature is that they support pretty much every major programming language. Now that my fanboying is over, let's go over Unleash in PHP!

Unleash in PHP

Installing the SDK is straightforward, simply run composer require unleash/client. The documentation can be found at Packagist or GitHub. It supports PHP versions as old as 7.2. Afterwards you create an instance of the Unleash object that you will use throughout your code:

$unleash = UnleashBuilder::create()
    ->withAppName('Some app name')
    ->withAppUrl('https://my-unleash-server.com/api/')
    ->withInstanceId('Some instance id')
    ->build();

The app name and instance ID are used to identify clients. The app URL is the Unleash server endpoint, which you can find in the settings page.

Once you've set up the Unleash object, using it is extremely simple:

if ($unleash->isEnabled('new-product-page')) {
  // do one thing
} else if ($unleash->isEnabled('semi-new-product-page')) {
  // do other thing
} else {
  // do yet another thing
}

If you do A/B testing, you can configure variants like this:

$topMenuVariant = $unleash->getVariant('top-menu');
if (!$topMenuVariant->isEnabled()) {
  // todo the user does not have access to the feature at all
} else {
  $payload = $topMenuVariant->getPayload();
  // let's assume the payload is a JSON
  assert($payload->getType() === VariantPayloadType::JSON);
  $payloadData = $payload->fromJson();

  // todo display the menu based on the received payload
}

Configuring the features

All of the above must be configured somewhere and that place is the Unleash UI. You can test out their official demo (just put whatever email in there, it doesn't even have to be real, there's no confirmation) if you don't want to install Unleash locally.

Each feature has multiple environments, by default a development and production one (I think in the open source version you cannot create more, though I successfully did so by fiddling directly with the database) and each environment must have one or more strategies (unless the environment is disabled). Strategies is what controls whether the feature is enabled for a user or not. I'll go briefly over the simple strategies and then write a bit more about the complex ones (and custom ones).

  1. Standard - simple yes/no strategy, no configuration, just enabled or disabled
  2. User IDs - enable the feature for specific user IDs
  3. IPs and Hosts - enable the feature for specific IP addresses and hostnames respectively

Unleash doesn’t automatically know your app’s user IDs—you need to provide them via an Unleash context:

$context = new UnleashContext(currentUserId: '123');

if ($unleash->isEnabled('some-feature', $context)) {
  // todo
}

Or more likely, if you don't want to pass around a manually created context all the time, just create a provider that will create the default context:

final class MyContextProvider implements UnleashContextProvider
{
    public function getContext(): Context
    {
        $context = new UnleashContext();
        $context->setCurrentUserId('user id from my app');

        return $context;     
    }
}

$unleash = UnleashBuilder::create()
    ->withAppName('Some app name')
    ->withAppUrl('https://my-unleash-server.com/api/')
    ->withInstanceId('Some instance id')
    ->withContextProvider(new MyContextProvider())
    ->build();

if ($unleash->isEnabled('some-feature')) {
  // todo
}

The Gradual rollout strategy

This powerful strategy allows you to roll out features to a percentage of users based on a chosen context field (e.g., user ID, IP address, or any custom attribute). With the help of constraints you can configure very complex access scenarios thanks to the many operators that are available (various string, array, date, numeric and version operators) for each of your context fields. So in short, you create arbitrary fields in your context which you can then validate with any of the supported operators.

This is sort of becoming the catch-all default strategy because it can do everything the others can with the help of constraints. If you want to emulate the Standard strategy, just make it always available to 100% of your users. Emulating User IDs strategy can be done by having it available to 100% of your userbase and adding a constraint that the userId must be one of the specified values. And so on.

Custom strategies

Need even more flexibility? You can create custom strategies! Here’s a real-world example from one of my projects:

<?php

namespace App\Service\Unleash;

use InvalidArgumentException;
use Unleash\Client\Configuration\Context;
use Unleash\Client\DTO\Strategy;
use Unleash\Client\Strategy\AbstractStrategyHandler;
use Override;

final class AccountIdUnleashStrategy extends AbstractStrategyHandler
{
    public const string CONTEXT_NAME = 'currentAccountId';

    #[Override]
    public function getStrategyName(): string
    {
        return 'accountId';
    }

    #[Override]
    public function isEnabled(Strategy $strategy, Context $context): bool
    {
        $allowedAccountIds = $this->findParameter('accountIds', $strategy);
        if (!$allowedAccountIds) {
            return false;
        }

        try {
            $currentCompanyAccountId = $context->getCustomProperty(self::CONTEXT_NAME);
        } catch (InvalidArgumentException) {
            return false;
        }

        $allowedAccountIds = array_map('trim', explode(',', $allowedAccountIds));
        $enabled = in_array($currentCompanyAccountId, $allowedAccountIds, true);

        if (!$enabled) {
            return false;
        }

        return $this->validateConstraints($strategy, $context);
    }
}

Then simply register it:

$unleash = UnleashBuilder::create()
    ->withAppName('Some app name')
    ->withAppUrl('https://my-unleash-server.com/api/')
    ->withInstanceId('Some instance id')
    ->withContextProvider(new MyContextProvider())
    ->withStrategy(new AccountIdUnleashStrategy())
    ->build();

The strategy is then simply created in Unleash where you add an accountIds field of type list and mark it as required. Note that this strategy could also be defined using a Gradual rollout strategy with constraints, but I think having a custom one like that provides a better developer experience.

One downside to custom strategies is that if you use them in different projects, you need to create them in each project and the behavior must be the same (meaning the same context fields and the same implementation even across languages).

Unleash in Symfony

The Unleash Symfony bundle handles most of the configuration for you and offers additional features, such as:

  • #[IsEnabled] attribute for controller routes
  • Automatic user ID if the Symfony Security component is configured
  • Automatic integration with the Symfony http request object, like fetching the remote IP from it instead of from the $_SERVER array
  • Automatic environment context value based on the kernel environment
  • Custom context properties configured either as static values, as Expression Language expressions or provided via an event listener
  • Twig functions, tags, tests and filters
  • Automatically registered custom strategies, you simply implement them and Unleash knows about them
  • and more

Additional notes

There are many other Unleash features I haven’t covered, such as the frontend proxy (which handles evaluation and prevents client-side state leakage). Some advanced features are better suited for official documentation rather than a blog post.

 

The problem

If you use a SQLite database in a Doctrine project and enable foreign key checks, you’ll run into an issue with table-modifying migrations: You often need to drop and fully recreate the table. If that table is referenced by others, the migration will fail unless you disable the foreign key checks. Furthermore, the entire migration runs inside a transaction, and SQLite doesn’t allow changing foreign key checks during a transaction.

The solution

There are several possible solutions, but here’s a particularly neat one made possible by PHP 8.4’s new property hooks:

final class VersionXXXXXXXXXXXXXX extends AbstractMigration
{
    protected $connection {
        get {
            $this->connection->executeStatement('PRAGMA foreign_keys = OFF');
            return $this->connection;
        }
        set => $this->connection = $value;
    }

    public function up(Schema $schema): void
    {
        // TODO create migration
    }

    public function down(Schema $schema): void
    {
        // TODO create migration
    }
}

The code above overrides the $connection property from the parent class with a property hook, so every time the migration system requests a connection, the foreign key checks are disabled.