Skip Navigation
ActivityPub: The Good, the Bad and the Ugly
  • Asi bude lepší použít můj Lemmy účet (@rikudou@lemmings.world), tohle je můj blog a vesměs není použitelný na komentáře mimo moje vlastní příspěvky, nic jiného se mi tu nezobrazuje.

  • ActivityPub: The Good, the Bad and the Ugly
  • I must admit I always forget XMPP exists (which seems to be a common trend). It doesn't support e2e, right? That doesn't really make it useful in modern times anymore (in my opinion).

  • ActivityPub: The Good, the Bad and the Ugly
  • Thanks! I'm not aware that any Fediverse software actually allows that, but hey, might be worth testing out what each software thinks about liking a like.

  • ActivityPub: The Good, the Bad and the Ugly
  • Kinda, but in a really weird way, nothing is getting standardized and everything is a de-facto standard (or not, especially Mastodon devs don't really care about the rest of Fediverse), which is not good for development.

  • ActivityPub: The Good, the Bad and the Ugly
  • One of my favourite xkcd images! And yeah, creating another competing standard would suck, what's needed is more of a slow evolution towards a sane standard.

    Edit: Didn't notice your edit before. Well, I think sending private messages has been part of social media when before it was called social media, so missing that functionality feels like it would unnecessarily hold the Fediverse back.

    As for encryption, I believe it is needed in some form because stuff like GDPR and other privacy laws might as well destroy the Fediverse if some bureaucrat decides they want to look into it.

  • ActivityPub: The Good, the Bad and the Ugly

    I know that title might seem controversial, so let's dive in. Right now, here’s the landscape of federated protocols:

    • ActivityPub - The shining star of this article.
    • OStatus - Mostly deprecated in favour of ActivityPub.
    • Diaspora - Exclusive to Diaspora, limiting interoperability.
    • Nostr - Infamous due to problematic user behaviour, unlikely to achieve significant interoperability.
    • Matrix - Could theoretically support social media use-cases but currently doesn't.
    • ATProto - Technically promising, yet hampered by corporate handling and mistrust from the open-source community.

    Ultimately, that leaves us with ActivityPub. Everything else either lacks widespread adoption, doesn’t support common social media scenarios, or is effectively proprietary despite being open-source. For those who prioritize open-source solutions, ActivityPub is essentially the only viable option.

    The Good ----------

    While I’m about to critique ActivityPub extensively, it undeniably has strong points:

    • Interoperability: The core idea is genuinely powerful. Using your Mastodon account to comment on a Lemmy post—or even reading this blog post on your preferred instance—is genuinely amazing. Different instances can display content uniquely, allowing users to interact in a way tailored to their platform.
    • Human-readable JSON: While some might underestimate this, JSON's human readability makes debugging and understanding ActivityPub interactions straightforward.
    • Extensible: Custom properties and types can extend functionality beyond initial design limitations, ensuring future flexibility.

    The Bad ----------

    Most issues with ActivityPub stem from one critical flaw: much of its behaviour is undefined. The core types and activities allow interactions that make little practical sense. For instance, the Like activity should be simple—you like something. But, in ActivityPub, you can "like" another Like activity, creating infinite loops of nonsensical interactions.

    This flexibility leads to a significant problem: no two implementations behave identically. Developers resort to hacks and guesswork to interpret undefined behaviour. Ideally, ActivityPub would strictly define interactions for core types, enabling implementations (Mastodon, Lemmy, Pleroma, etc.) to focus solely on presentation or extending functionality, knowing basic interactions remain consistent across platforms.

    A practical example is the confusion around private messages. Two competing methods have emerged: a custom ChatMessage type not officially supported by ActivityPub (used by Lemmy, Pleroma and others), and an alternate "standard" using a Note object that excludes the public audience but explicitly mentions recipients (used by Mastodon and others). This ambiguity creates compatibility nightmares.

    Another example I personally encountered was a frustrating issue while implementing ActivityPub for this blog: updating a post propagated to Lemmy but not Mastodon. Despite the Update activity being accepted, Mastodon silently rejected it unless the updated timestamp changed—a logical but unofficial requirement. Developers must track down subtle implementation details that aren't formally documented, significantly complicating adoption and usage.

    The Ugly ----------

    Privacy is virtually non-existent. When another server federates with yours, it receives all public activities, which might seem harmless initially. However, what happens if you mistakenly share sensitive information publicly? In theory, deleting a post propagates across the network, but real-world scenarios vary greatly—from technical glitches to outright malicious actors ignoring delete requests. Ensuring robust privacy requires substantial protocol-level changes, such as introducing end-to-end encryption—something notoriously complex to implement, as evidenced by Matrix’s struggles.

    Another significant flaw is impersonation vulnerability. ActivityPub itself has no built-in authentication mechanism, meaning anyone could theoretically impersonate any user. Although most implementations use the HTTP Signatures standard to address this, ActivityPub itself remains incomplete in terms of essential security features. The standard openly acknowledges:

    > > > Unfortunately at the time of standardization, there are no strongly agreed upon mechanisms for authentication. > >

    Conclusion ----------

    ActivityPub, particularly its vocabulary rules (ActivityStreams), remains a half-finished protocol. Its effectiveness depends heavily on individual implementation choices, creating problematic discrepancies—such as the inability to reliably send private messages between Mastodon and Lemmy users. Moreover, simple human errors or software oversights can unintentionally expose private information, as recently demonstrated when new Fediverse software mishandled Mastodon-style private messages and displayed them publicly.

    The solution? ActivityPub needs a clearly defined second iteration—an ActivityPub v2—that eliminates ambiguity, standardizes behaviour strictly, and provides essential security measures. Certain issues, especially privacy, may never be fully resolved within the protocol, but increased clarity and stricter rules would significantly mitigate existing risks.

    This doesn’t mean we should abandon ActivityPub, but rather, we must work collectively to standardize it further, making it more secure and less error-prone.

    What are your thoughts on ActivityPub? Have you developed something using it? Are you planning to? Let me know in the comments!

    23
    Static Typing for the AWS SDK for PHP

    > > > If you just want to install it without reading the whole article, you can install it via Composer: rikudou/aws-sdk-phpstan. > >

    When using PHPStan alongside AWS, you end up making a lot of type assertions, because the automatically generated AWS SDK doesn’t strictly define types, so everything defaults to mixed. Fortunately, the SDK package includes the source data used to generate itself, so I reused that information to create a PHPStan extension. This extension provides precise type definitions, catches all type-related errors for return types, and allows you to remove those otherwise unnecessary type assertions.

    How It’s Made ----------

    As mentioned earlier, if all you want is to install and use the package, you don’t really need this article. But if you want a closer look at how it works, read on.

    The first step was to make the Result class (which is returned by all API calls) generic by providing a custom stub—particularly for its get() method:

    ``` /**

    • @template T of array<string, mixed>
    • @implements ResultInterface<T> / class Result implements ResultInterface, MonitoringEventsInterface { /*
      • @template TKey of (key-of<T>|string)
      • @param TKey $key
      • @return (TKey is key-of<T> ? T[TKey] : null) */ public function get(string $key): mixed {} } ```

    The class itself is generic, constrained to an array. The get() method is also made generic based on the key. If the key is a known key of T, the method returns its corresponding value; otherwise, it returns null. Essentially, if the response type expects the property, it’s returned—if not, null is returned.

    The Individual Clients ----------

    All the client classes are generated from the type definitions in the src/data directory of the official AWS SDK for PHP. Each client’s definitions come in two files, such as:

    (These map one-to-one with the PHP client methods. You’ll notice that the actual PHP client just uses __call() for these methods.)

    For example, in the JSON definition for S3Client, you might see:

    { "GetObject":{ "name":"GetObject", "http":{ "method":"GET", "requestUri":"/{Bucket}/{Key+}" }, "input":{"shape":"GetObjectRequest"}, "output":{"shape":"GetObjectOutput"}, "errors":[ {"shape":"NoSuchKey"}, {"shape":"InvalidObjectState"} ], "documentationUrl":"http://docs.amazonwebservices.com/AmazonS3/latest/API/RESTObjectGET.html", "httpChecksum":{ "requestValidationModeMember":"ChecksumMode", "responseAlgorithms":[ "CRC64NVME", "CRC32", "CRC32C", "SHA256", "SHA1" ] } } }

    And in PHP:

    ``` use Aws\S3\S3Client;

    $client = new S3Client([]); $object = $client->getObject([ 'Bucket' => 'test', 'Key' => 'test', ]);

    ```

    In reality, these methods don’t actually exist in the client class; they’re invoked through __call() under the hood.

    Going back to the JSON definitions, each operation has an input and an output shape. The package currently only focuses on the output shape, although I plan to add input shape support in the future. For the GetObjectOutput, the relevant shape might be:

    { "GetObjectOutput":{ "type":"structure", "members":{ "Body":{ "shape":"Body", "streaming":true }, "DeleteMarker":{ "shape":"DeleteMarker", "location":"header", "locationName":"x-amz-delete-marker" }, "AcceptRanges":{ "shape":"AcceptRanges", "location":"header", "locationName":"accept-ranges" }, "Expiration":{ "shape":"Expiration", "location":"header", "locationName":"x-amz-expiration" }, "Restore":{ "shape":"Restore", "location":"header", "locationName":"x-amz-restore" }, "LastModified":{ "shape":"LastModified", "location":"header", "locationName":"Last-Modified" } } }

    > > > (Note: The actual shape is larger, but I’ve omitted some fields to keep this article shorter.) > >

    Generating Type Extensions ----------

    PHPStan lets you add extensions that generate return types based on the method call and its input parameters. I decided to take that approach, though there are other possibilities (such as generating a stub file for each client).

    For every client, a class like {ShortAwsClientClassName}TypeExtension is generated, for example, S3ClientReturnTypeExtension. The isMethodSupported() method just checks if the method name matches one of the operations defined in the JSON file. Then there’s a getTypeFromMethodCall() method that uses a match expression to call a private method of the same name.

    Those private methods return PHPStan types derived from the shapes in the JSON data. The generator supports lists, maps, nested structures, binary blobs, date/time objects, enums, and simple types (strings, booleans, integers, floats), including unions, nested arrays, and more.

    If you want to dive into the code:

    As a final touch, the extension.neon file (which registers extensions with PHPStan) is populated automatically with each generated class.

    Performance ----------

    The performance isn’t ideal if you include every single client class by default—which is understandable considering there are around 400 classes, each containing thousands of lines. Most projects likely won’t use all 400 AWS services in one codebase. That’s why I provided a generation script as a Composer binary, along with support for specifying only the clients you need by updating your composer.json:

    { "extra": { "aws-sdk-phpstan": { "only": [ "Aws\\S3\\S3Client", "Aws\\CloudFront\\CloudFrontClient" ] } } }

    After that, run vendor/bin/generate-aws-phpstan to regenerate the classes. The script deletes all existing type extensions first, then generates only for S3Client and CloudFrontClient. It also updates the extensions.neon file with just those extensions. With only a few extensions active, there’s no noticeable slowdown in PHPStan.

    You can also leverage Composer's script events to run the binary automatically:

    { "extra": { "aws-sdk-phpstan": { "only": [ "Aws\\S3\\S3Client", "Aws\\CloudFront\\CloudFrontClient" ] } }, "scripts": { "post-install-cmd": [ "generate-aws-phpstan" ], "post-update-cmd": [ "generate-aws-phpstan" ] } }

    Ideally, the official SDK itself would include type definitions (it shouldn’t be too difficult, given they already generate the SDK from these JSON files). In the meantime, though, I’m pretty happy with how this little project turned out.

    0
    Transpiling PHP for older versions
  • Hmm, that build number would definitely be better! I tried adding another level (like 1.0.0.84) but that didn't work. I'll try it with the build numbers.

    I wrote a GitHub workflow that transpiles it on release and tags each transpiled version, then pushes those tags to the repo. Packagist automatically fetches tags, so it gets them automatically.

    For example, the tag v2.7.082.

  • Transpiling PHP for older versions

    The Problem ----------

    Every developer wants to use the latest and greatest features of their tools, and PHP is no exception. But sometimes you simply can’t upgrade—whether because of project constraints or because your users are still on an older PHP version. For instance, if you’re building a library, you’ll often need to target a version that’s a few releases behind the latest, so you’re not forcing your users to upgrade before they’re ready.

    The Solution ----------

    Transpiling! Instead of writing code that only works on a modern PHP version, you write it using the newest features and then transpile it down to your target PHP version. One of the best tools for this job is Rector. You might know Rector as the tool that automatically upgrades your code to a newer version of PHP—but it works in reverse as well. Downgrading is just as easy. For example, to downgrade your code to PHP 7.4, your rector.php file can be as simple as this:

    ``` <?php

    declare(strict_types=1);

    use Rector\Config\RectorConfig;

    return RectorConfig::configure() ->withPaths([ DIR . '/src', ]) ->withDowngradeSets(php74: true) ;

    ```

    Now, simply run Rector as you normally would (for example, vendor/bin/rector process), and you’re all set..

    As an example, here’s a class that uses many modern PHP features:

    ``` final readonly class ModernClass { final protected const string TYPED_FINAL_CONSTANT = 'some-string';

    public function __construct( public int $promotedProperty, private stdClass $data = new stdClass(), ) { // new without parenthesis $selfName = new ReflectionClass($this)->getName(); // named parameters and the new rounding mode enum $rounded = round(5.5, mode: RoundingMode::HalfTowardsZero);

    // previously those functions only worked with Traversable instances, in PHP 8.2 they work with both Traversable and array instances $array = [1, 2, 3]; $count = iterator_count($array); $array = iterator_to_array($array);

    $callable = $this->methodThatReturnsNever(...); $callable(); }

    private function methodThatReturnsNever(): never { throw new Exception(); }

    // standalone false/true/null type public function returnTrue(): true { return true; } public function returnFalse(): false { return false; } public function returnNull(): null { return null; } }

    ```

    And here’s what it looks like after downgrading:

    ``` final class ModernClass { /** * @readonly / public int $promotedProperty; /* * @readonly / private stdClass $data; /* * @var string */ protected const TYPED_FINAL_CONSTANT = 'some-string';

    public function __construct( int $promotedProperty, ?stdClass $data = null ) { $data ??= new stdClass(); $this->promotedProperty = $promotedProperty; $this->data = $data; // new without parenthesis $selfName = (new ReflectionClass($this))->getName(); // named parameters and the new rounding mode enum $rounded = round(5.5, 0, \PHP_ROUND_HALF_DOWN);

    // previously those functions only worked with Traversable instances, in PHP 8.2 they work with both Traversable and array instances $array = [1, 2, 3]; $count = iterator_count(is_array($array) ? new \ArrayIterator($array) : $array); $array = iterator_to_array(is_array($array) ? new \ArrayIterator($array) : $array);

    $callable = \Closure::fromCallable([$this, 'methodThatReturnsNever']); $callable(); }

    /** * @return never */ private function methodThatReturnsNever() { throw new Exception(); }

    // standalone false/true/null type /** * @return true / public function returnTrue(): bool { return true; } /* * @return false / public function returnFalse(): bool { return false; } /* * @return null */ public function returnNull() { return null; } } ```

    This is now a perfectly valid PHP 7.4 class. It’s amazing to see how much PHP has evolved since 7.4—not to mention compared to the old 5.x days. I personally can’t live without property promotion anymore.

    > > > Note: Not every piece of modern PHP code can be downgraded automatically. For example, Rector leaves the following property definitions unchanged: > > > > > public bool $hooked { > get => $this->hooked; > } > public private(set) bool $asymmetric = true; > > > > > I assume support for downgrading asymmetric visibility will eventually be added, but hooked properties are very hard to downgrade in general—even though in some specialized cases they could be converted to readonly properties. > >

    Downgrading Your Composer Package ----------

    If you want to write your package using modern PHP features but still support older PHP versions, you need a way to let Composer know which version to install. One simple approach would be to publish a separate package for each PHP version—say, the main package as vendor/package and additional ones like vendor/package-82, vendor/package-74, etc. While this works, it has a drawback. For instance, if you’re on PHP 8.3 and later upgrade your main package to PHP 8.4, you’d have to force users to switch to a new package (say, vendor/package-83), rendering the package incompatible for anyone still on an older PHP version.

    Instead, I leverage two behaviors of Composer:

    1. It always tries to install the newest version that matches your version constraints.
    2. It picks the latest version that is supported by the current environment.

    This means you can add a suffix to each transpiled version. For version 1.2.0, you might have:

    • 1.2.084 (for PHP 8.4)
    • 1.2.083 (for PHP 8.3)
    • 1.2.082 (for PHP 8.2)
    • 1.2.081 (for PHP 8.1)
    • 1.2.080 (for PHP 8.0)
    • 1.2.074 (for PHP 7.4)

    When someone runs composer require vendor/package, Composer will select the version with the highest version number that is compatible with their PHP runtime. So, a user on PHP 8.4 gets 1.2.084, while one on PHP 8.2 gets 1.2.082. If you use the caret (^) or greater-than-or-equal (>=) operator in your composer.json, you also future-proof your package: if someone with a hypothetical PHP 8.5 tries to install it, they’ll still get the highest compatible version (in this case, 1.2.084).

    Of course, you’ll need to run the transpilation before each release and automatically update your composer.json file. For older PHP versions, you might also have to make additional adjustments. In one package I worked on, I had to include extra polyfills for PHP 7.2 and even downgrade PHPUnit—but overall, the process works really well.

    You can see this approach in action in the Unleash PHP SDK. More specifically, check out this workflow file and, for example, this commit which shows all the changes involved when transpiling code from PHP 8.3 down to PHP 7.2.

    > > > Caveat: One important downside of this approach is that if a user installs the package in an environment that initially has a newer PHP version than the one where the code will eventually run (or where dependencies will be installed), Composer might install a version of the package that the actual runtime cannot handle. > >

    I believe this approach offers the best of both worlds when writing packages. You get to enjoy all the modern PHP features (I can’t live without constructor property promotion, and public readonly properties are fantastic for writing simple DTOs), while still supporting users who aren’t able—or ready—to upgrade immediately.

    It’s also a powerful tool if your development team can upgrade PHP versions faster than your server administrators. You can write your app using the latest syntax and features, and then transpile it to work on the servers that are actually in use.

    So, what do you think? Is this an approach you or your team might consider?

    2
    OpenSCAD configurable calendar 3D model
  • By the way, as mentioned in the post, if anyone can recommend a good algorithm to calculate Easter, let me know!

  • OpenSCAD configurable calendar 3D model

    OpenSCAD is truly amazing in a way that no other 3D modeling software is, including those with limited scripting abilities.

    You can implement standard algorithms from general-purpose languages, like the impressive Zeller's Congruence used to calculate the day of the week for any given date. I utilized this to make the calendar automatically adjust the date offset. Simply change the year number in the configurator, and the model remains accurate:

    [!A calendar model screenshot for year 2025, showing that the Jan 1st is a Wednesday](/i/o/14/calendar-year-2025.png)

    According to my computer, Jan 1st, 2025, is indeed a Wednesday.

    [!A screenshot of a 3D model showing that Jan 1st 2056 is a Saturday](/i/o/15/calendar-year-2056.png)

    A quick calendar check confirms that Jan 1st, 2056, is a Saturday!

    Here’s the OpenSCAD function:

    function getFirstDay(year, month, day = 1) = let ( q = day, m = month < 3 ? month + 12 : month, adjusted_year = month < 3 ? year - 1 : year, K = (adjusted_year) % 100, J = floor((adjusted_year) / 100) ) ( let ( h = (q + floor((13 * (m + 1)) / 5) + K + floor(K / 4) + floor(J / 4) + 5 * J) % 7 ) ((h + 5) % 7) + 1 );

    I kept the variable names consistent with the Wikipedia page for easier verification.

    Additionally, I included a generic leap year check and a function to get the correct number of days in a month:

    function daysAmount(month) = month == 2 ? (year % 4 == 0 && (year % 400 == 0 || year % 100 != 0)) ? 29 : 28 : (month % 2 == 0 ? (month >= 8 ? 31 : 30) : (month >= 8 ? 30 : 31));

    Working with dates is always a “pleasure,” but doing so in a language with no built-in date support was especially interesting!

    This project is highly user-friendly with multiple configurable options, including:

    • Selection of months to render, column layout, and layer height adjustments for multi-material printing.
    • Custom holiday markings, such as highlighting Saturdays in red and adding holidays through a comma-separated list.
    • Full translation support for titles, month names, and day names.
    • Configurable holes for magnets and screws to mount on fridges or walls.

    Some options leverage libraries like JustinSDK/dotSCAD and davidson16807/relativity.scad lor string manipulation (e.g., replacing %year in the title with the selected year or splitting holiday dates).

    The model is available on Makerworld. If it ever gets taken down (possibly due to my dissatisfaction with the recent Bambu firmware changes), here’s the full source code:

    ``` /**

    • MIT License
    • Copyright (c) 2025 Dominik Chrástecký
    • Permission is hereby granted, free of charge, to any person obtaining a copy
    • of this software and associated documentation files (the "Software"), to deal
    • in the Software without restriction, including without limitation the rights
    • to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
    • copies of the Software, and to permit persons to whom the Software is
    • furnished to do so, subject to the following conditions:
    • The above copyright notice and this permission notice shall be included in all
    • copies or substantial portions of the Software.
    • THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
    • IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
    • FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
    • AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
    • LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
    • OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
    • SOFTWARE. */

    /* [What to render] */ // Whether to render the red parts (holidays, Sundays, Saturdays if enabled) redParts = true; // Whether to render the white parts (background) whiteParts = true; // Whether to render the black parts (dates, text) blackParts = true; // Whether to render the blue parts (background behind month names) blueParts = true;

    /* [General] */ // The year to generate the calendar for year = 2024; // The start month, useful if you want to print the calendar in multiple parts startMonth = 1; // The end month, useful if you want to print the calendar in multiple parts endMonth = 12; // comma separated holiday dates with day first and month second, for example: 1.1,8.5,5.7,6.7 (means Jan 1st, May 8th, Jul 5th, Jul 6th) holidays = ""; // Whether you want to print using AMS, MMU or a similar system, or a single extruder version multiMaterial = true; // The height of the calendar calendarHeight = 3.2; // a number between 10 and 360, the higher the better quality quality = 60; // [10:360] // whether Saturdays should be rendered in red in addition to Sundays saturdayRedColor = false; // how many months to put on a single row monthsPerRow = 3;

    /* [Hook and magnet holes] */ // Enable hook holes? hookHole = true; // Enable magnet hole? magnetHole = true; // How much to add to the various sizes, if your printer is not well calibrated, you might need to make the tolerances larger tolerances = 0.2; // The diameter of the lower part of the hook hole hookHoleDiameter = 5.6; // The width of the upper part of the hook hole hookHoleUpperPartWidth = 3; // Whether the magnet is round or square roundMagnet = true; // The diameter of the magnet, ignored if the magnet is not round magnetDiameter = 10; // The width of the magnet, ignored if the magnet is round magnetWidth = 10; // The depth of the magnet, ignored if the magnet is round magnetDepth = 10; // The height of the magnet hole. Please make sure the calendarHeight is larger than the magnet hole, otherwise weird stuff might happen magnetHeight = 2; // When checked, the magnet hole will be hidden inside the calendar and you will have to pause the print to insert the magnet, if unchecked, the magnet hole will be visible on the back hiddenMagnet = true;

    /* [Text settings] */ // The name of the font to use font = "Liberation Mono:style=Bold"; // The size of the month names monthFontSize = 5.01; // The size of the font for name days dayNameFontSize = 2.51; // The size of the font for calendar title titleFontSize = 10.01;

    /* [Calendar title] */ // The title of the calendar, %year will be replaced with the current year calendarTitle = "Calendar %year"; // The space around the calendar title, make larger if your magnet is too big to fit titleSpace = 15;

    /* [Day names] */ // Your language version for Monday monday = "MON"; // Your language version for Tuesday tuesday = "TUE"; // Your language version for Wednesday wednesday = "WED"; // Your language version for Thursday thursday = "THU"; // Your language version for Friday friday = "FRI"; // Your language version for Saturday saturday = "SAT"; // Your language version for Sunday sunday = "SUN";

    /* [Month names] */ // Your language version for January january = "JANUARY"; // Your language version for February february = "FEBRUARY"; // Your language version for March march = "MARCH"; // Your language version for April april = "APRIL"; // Your language version for May may = "MAY"; // Your language version for June june = "JUNE"; // Your language version for July july = "JULY"; // Your language version for August august = "AUGUST"; // Your language version for September september = "SEPTEMBER"; // Your language version for October october = "OCTOBER"; // Your language version for November november = "NOVEMBER"; // Your language version for December december = "DECEMBER";

    function getFirstDay(year, month, day = 1) = let ( q = day, m = month < 3 ? month + 12 : month, adjusted_year = month < 3 ? year - 1 : year, K = (adjusted_year) % 100, J = floor((adjusted_year) / 100) ) ( let ( h = (q + floor((13 * (m + 1)) / 5) + K + floor(K / 4) + floor(J / 4) + 5 * J) % 7 ) ((h + 5) % 7) + 1 );

    // from https://github.com/JustinSDK/dotSCAD/blob/master/src/util/_impl/_split_str_impl.scad function sub_str(t, begin, end) = let( ed = is_undef(end) ? len(t) : end, cum = [ for (i = begin, s = t[i], is_continue = i < ed; is_continue; i = i + 1, is_continue = i < ed, s = is_continue ? str(s, t[i]) : undef) s ] ) cum[len(cum) - 1];

    function _split_t_by(idxs, t) = let(leng = len(idxs)) [sub_str(t, 0, idxs[0]), each [for (i = 0; i < leng; i = i + 1) sub_str(t, idxs[i] + 1, idxs[i + 1])]];

    function daysAmount(month) = month == 2 ? (year % 4 == 0 && (year % 400 == 0 || year % 100 != 0)) ? 29 : 28 : (month % 2 == 0 ? (month >= 8 ? 31 : 30) : (month >= 8 ? 30 : 31));

    function split_str(t, delimiter) = len(search(delimiter, t)) == 0 ? [t] : _split_t_by(search(delimiter, t, 0)[0], t);

    function contains(value, array) = count_true([for (element = array) element == value]) > 0;

    function count_true(values) = sum([for (v = values) v ? 1 : 0]);

    function sum(values) = sum_helper(values, 0);

    function sum_helper(values, i) = i < len(values) ? values[i] + sum_helper(values, i + 1) : 0;

    // from https://github.com/davidson16807/relativity.scad/blob/master/strings.scad function replace(string, replaced, replacement, ignore_case=false, regex=false) = _replace(string, replacement, index_of(string, replaced, ignore_case=ignore_case, regex=regex));

    function _replace(string, replacement, indices, i=0) = i >= len(indices)? after(string, indices[len(indices)-1].y-1) : i == 0? str( before(string, indices[0].x), replacement, _replace(string, replacement, indices, i+1) ) : str( between(string, indices[i-1].y, indices[i].x), replacement, _replace(string, replacement, indices, i+1) ) ;

    function after(string, index=0) = string == undef? undef : index == undef? undef : index < 0? string : index >= len(string)-1? "" : join([for (i=[index+1:len(string)-1]) string[i]]) ; function before(string, index=0) = string == undef? undef : index == undef? undef : index > len(string)? string : index <= 0? "" : join([for (i=[0:index-1]) string[i]]) ; function join(strings, delimeter="") = strings == undef? undef : strings == []? "" : _join(strings, len(strings)-1, delimeter); function _join(strings, index, delimeter) = index==0 ? strings[index] : str(_join(strings, index-1, delimeter), delimeter, strings[index]) ;

    function index_of(string, pattern, ignore_case=false, regex=false) = _index_of(string, regex? _parse_rx(pattern) : pattern, regex=regex, ignore_case=ignore_case); function _index_of(string, pattern, pos=0, regex=false, ignore_case=false) = //[start,end] pos == undef? undef : pos >= len(string)? [] : _index_of_recurse(string, pattern, _index_of_first(string, pattern, pos=pos, regex=regex, ignore_case=ignore_case), pos, regex, ignore_case) ;

    function _index_of_recurse(string, pattern, index_of_first, pos, regex, ignore_case) = index_of_first == undef? [] : concat( [index_of_first], _coalesce_on( _index_of(string, pattern, pos = index_of_first.y, regex=regex, ignore_case=ignore_case), undef, []) ); function _index_of_first(string, pattern, pos=0, ignore_case=false, regex=false) = pos == undef? undef : pos >= len(string)? undef : _coalesce_on([pos, _match(string, pattern, pos, regex=regex, ignore_case=ignore_case)], [pos, undef], _index_of_first(string, pattern, pos+1, regex=regex, ignore_case=ignore_case)) ;

    function _coalesce_on(value, error, fallback) = value == error? fallback : value ; function _match(string, pattern, pos, regex=false, ignore_case=false) = regex? _match_parsed_peg(string, undef, pos, peg_op=pattern, ignore_case=ignore_case)[_POS] : starts_with(string, pattern, pos, ignore_case=ignore_case)? pos+len(pattern) : undef ; function starts_with(string, start, pos=0, ignore_case=false, regex=false) = regex? _match_parsed_peg(string, undef, pos, _parse_rx(start), ignore_case=ignore_case) != undef : equals( substring(string, pos, len(start)), start, ignore_case=ignore_case) ; function equals(this, that, ignore_case=false) = ignore_case? lower(this) == lower(that) : this==that ; function substring(string, start, length=undef) = length == undef? between(string, start, len(string)) : between(string, start, length+start) ; function between(string, start, end) = string == undef? undef : start == undef? undef : start > len(string)? undef : start < 0? before(string, end) : end == undef? undef : end < 0? undef : end > len(string)? after(string, start-1) : start > end? undef : start == end ? "" : join([for (i=[start:end-1]) string[i]]) ;

    module _radiusCorner(depth, radius) { difference(){ translate([radius / 2 + 0.1, radius / 2 + 0.1, 0]){ cube([radius + 0.2, radius + 0.1, depth + 0.2], center=true); }

    cylinder(h = depth + 0.2, r = radius, center=true); } }

    module roundedRectangle(width, height, depth, radius, leftTop = true, leftBottom = true, rightTop = true, rightBottom = true) { translate([width / 2, height / 2, depth / 2]) difference() { cube([ width, height, depth, ], center = true); if (rightTop) { translate([width / 2 - radius, height / 2 - radius]) { rotate(0) { _radiusCorner(depth, radius); } } } if (leftTop) { translate([-width / 2 + radius, height / 2 - radius]) { rotate(90) { _radiusCorner(depth, radius); } } } if (leftBottom) { translate([-width / 2 + radius, -height / 2 + radius]) { rotate(180) { _radiusCorner(depth, radius); } } } if (rightBottom) { translate([width / 2 - radius, -height / 2 + radius]) { rotate(270) { _radiusCorner(depth, radius); } } } } }

    $fn = quality;

    holidaysArray = split_str(holidays, ","); hasHolidays = !(len(holidaysArray) == 1 && holidaysArray[0] == "");

    plateWidth = 80;

    colorWhite = "#ffffff"; colorBlue = "#2323F7"; colorBlack = "#000000"; colorRed = "#ff0000";

    noMmuBlueOffset = 0.4; noMmuBlackOffset = 0.8; noMmuRedOffset = 1.2; noMmuWhiteOffset = 1.6;

    module monthBg(plateWidth, plateDepth, depth, margin) { height = 0.6; radius = 4;

    translate([ margin, plateDepth - depth - 5, calendarHeight - height + 0.01 ]) roundedRectangle( plateWidth - margin * 2, depth, height + (multiMaterial ? 0 : noMmuBlueOffset), radius ); }

    module monthName(month, plateWidth, plateDepth, bgDepth) { height = 0.6;

    monthNames = [january, february, march, april, may, june, july, august, september, october, november, december];

    color(colorWhite) translate([ plateWidth / 2, plateDepth - bgDepth - 3, calendarHeight - height + 0.02 ]) linear_extrude(height + (multiMaterial ? 0 : noMmuWhiteOffset)) text(monthNames[month - 1], size = monthFontSize, font = font, halign = "center"); }

    module dayName(day, margin, plateWidth, plateDepth) { height = 0.6; days = [monday, tuesday, wednesday, thursday, friday, saturday, sunday];

    space = (plateWidth - margin * 2) / 7 + 0.4;

    translate([ margin + (day - 1) * space, plateDepth - 20, calendarHeight - height + 0.01 ]) linear_extrude(height + (multiMaterial ? 0 : (day == 7 ? noMmuRedOffset : noMmuBlackOffset))) text(days[day - 1], size = dayNameFontSize, font = font); }

    module dayNumber(day, month, startOffset, plateWidth, plateDepth, margin) { height = 0.6; space = (plateWidth - margin * 2) / 7 + 0.4;

    index = (startOffset + day) % 7; stringDate = str(day, ".", month);

    isRed = index == 0 || saturdayRedColor && index == 6 || (hasHolidays && contains(stringDate, holidaysArray));

    translate([ margin + ((startOffset + day - 1) % 7) * space, plateDepth - 25 - floor((startOffset + day - 1) / 7) * 5, calendarHeight - height + 0.01 ]) linear_extrude(height + (multiMaterial ? 0 : (isRed ? noMmuRedOffset : noMmuBlackOffset))) text(str(day), size = dayNameFontSize, font = font); }

    module monthPlate(year, month) { plateDepth = 55; monthBgDepth = 9; margin = 5;

    if (whiteParts) { difference() { color(colorWhite) cube([plateWidth, plateDepth, calendarHeight]);

    monthBg(plateWidth, plateDepth, monthBgDepth, margin = margin);

    for (day = [1:7]) { dayName(day, margin = margin, plateWidth = plateWidth, plateDepth = plateDepth); }

    for (day = [1:daysAmount(month)]) { startOffset = getFirstDay(year, month) - 1; dayNumber(day, month, startOffset, plateWidth = plateWidth, margin = margin, plateDepth = plateDepth); } }

    monthName(month, plateWidth, plateDepth, monthBgDepth); } if (blueParts) { difference() { color(colorBlue) monthBg(plateWidth, plateDepth, monthBgDepth, margin = margin); monthName(month, plateWidth, plateDepth, monthBgDepth); } }

    for (day = [1:7]) { if (((day == 7 || day == 6 && saturdayRedColor) && redParts) || (!(day == 7 || day == 6 && saturdayRedColor) && blackParts)) { color(day == 7 || day == 6 && saturdayRedColor ? colorRed : colorBlack) dayName(day, margin = margin, plateWidth = plateWidth, plateDepth = plateDepth); } }

    for (day = [1:daysAmount(month)]) { startOffset = getFirstDay(year, month) - 1; index = (startOffset + day) % 7;

    stringDate = str(day, ".", month); isRed = index == 0 || saturdayRedColor && index == 6 || (hasHolidays && contains(stringDate, holidaysArray));

    if ((isRed && redParts) || (!isRed && blackParts)) { color(isRed ? colorRed : colorBlack) dayNumber(day, month, startOffset, plateWidth = plateWidth, margin = margin, plateDepth = plateDepth); } } }

    module title(bgHeight) { height = 0.6;

    translate([ (plateWidth * monthsPerRow) / 2, bgHeight / 2, calendarHeight - height + 0.01 ]) linear_extrude(height + (multiMaterial ? 0 : noMmuBlackOffset)) text(replace(calendarTitle, "%year", year), size = titleFontSize, halign = "center", valign = "center"); }

    module hookHole() { height = calendarHeight + 1; translate([hookHoleDiameter / 2, hookHoleDiameter / 2, -0.01]) { translate([-hookHoleUpperPartWidth / 2, hookHoleDiameter / 5.6, 0]) roundedRectangle(hookHoleUpperPartWidth + tolerances, 6, height, 1.5); cylinder(h = height, d = hookHoleDiameter + tolerances); } }

    for (month = [startMonth:endMonth]) { translate([ ((month - startMonth) % monthsPerRow) * plateWidth, -(ceil((month - startMonth + 1) / monthsPerRow)) * 55, 0 ]) monthPlate(year, month); }

    titleHeight = titleSpace;

    if (whiteParts) {

    color(colorWhite) difference() { cube([plateWidth * monthsPerRow, titleHeight, calendarHeight]); title(titleHeight);

    if (hookHole) { margin = 10;

    translate([margin, 3]) hookHole();

    translate([plateWidth * monthsPerRow - margin - hookHoleDiameter, 3]) hookHole(); }

    if (magnetHole) { translate([0, 0, hiddenMagnet ? 0.4 : 0]) { if (roundMagnet) { translate([ (plateWidth * monthsPerRow) / 2, magnetDiameter / 2 + 1, -0.01 ]) cylinder(h = magnetHeight + tolerances, d = magnetDiameter + tolerances); } else { translate([ (plateWidth * monthsPerRow) / 2 - magnetWidth / 2, magnetDepth / 2, -0.01 ]) cube([magnetWidth + tolerances, magnetDepth + tolerances, magnetHeight + tolerances]); } } } } } if (blackParts) { color(colorBlack) title(titleHeight); } ```

    In a future update, I plan to implement an algorithm to calculate Easter, allowing it to be added to holidays with a single toggle. If you know of any algorithm that could be easily implemented in OpenSCAD, let me know!

    1
    Persistent packages on Steam Deck using Nix
  • You mean the blog post I wrote myself and published on my ActivityPub enabled blog? That kind of "spam bot"?

  • Persistent packages on Steam Deck using Nix

    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

    2
    Lazy objects in PHP 8.4

    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($keyPair, $privateKeyPem); $reflection->getProperty('publicKey')->setValue($keyPair, $publicKeyPem); }); assert($keyPair instanceof KeyPair);

    return $keyPair; } ```

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

    0
    Strongly typed ng-template in Angular

    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.

    0
    Unleash: Feature flags in PHP

    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.

    0
    Doctrine and SQLite migrations: How to disable foreign keys in PHP 8.4

    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.

    0
    dominik Dominik Chrástecký - Blog @chrastecky.dev

    I'm a developer and an architect with \~10 years of experience. My languages of choice are PHP (Symfony), C#, Go and Typescript. With a tiny bit of Java and C++ here and there. I often write open source libraries, you can find my work on my GitHub. I started this blog for 3 reasons: I've wanted to start a blog for the last 15 years but never had time, I wanted a personal place where I can share whatever's on my mind, and I wanted to create something that uses ActivityPub.

    Posts 9
    Comments 11
    Moderates