Spyke

Posts

Writing Your Own Framework in PHP: Part One

What, how and why (aka the intro)

We'll be creating a Symfony/Spring inspired framework in PHP 8.5 and we'll be creating it from scratch. As for why, let me answer with a picture:

Because we can! In all seriousness, though, this is the best way to understand all the magic going on during a request. Spoiler alert: it's not that magical, actually.

What we won't be creating

We won't be creating an ORM and instead we'll wire in some existing solution when we reach that part. A similar tutorial for an ORM might come later but it would take half of this series just to get a working ORM to plug in.

We won't be creating custom templating system because it would take up another half of this series. That means we would spend half the time on templating, half on the ORM and half on the rest. That adds up to 3 halves and we don't want to break maths just so we can have custom templating, do we? 

We won't be making a production-ready framework — we will intentionally not be solving all the edge cases and performance issues that you'd expect from a production-ready framework (for example, our DI container will be evaluated at runtime and not during build time) because those are the boring parts that don't help much with understanding how exactly the framework works.

We won't be creating a perfect Symfony clone — this will be written entirely from scratch without reading any other framework's source code to avoid simply copying other project's internals. I expect this decision to bite me eventually but that's gonna be part of the fun.

What will we create in Part One?

We'll be creating the very basic bootstrapping:

  • Request
  • Response
  • Kernel
  • entrypoint

Step 0: Code Structure

I'll be splitting the code into two parts: app and framework. App is where the application code (like actual services, controllers etc.) will live, while framework is where all the internals will.

The most basic structure will look like this:

.
├── app
│   ├── public
│   └── src
└── framework
    └── src

Accompanied by a composer.json for autoloading:

{
  "autoload": {
    "psr-4": {
      "The\\Framework\\": "framework/src/",
      "The\\App\\": "app/src/"
    }
  }
}

If you're wondering why this split, it's to simulate our framework being installed as a dependency of our app. We could even make it actually installa ble but it would add way too much overhead. This also implies an important rule: our app can depend on our framework**, but our framework cannot depend on our app**.

Step 1: The Entrypoint & Kernel

The entrypoint is a script that the request first hits and it bootstraps everything. The kernel is a class that bootstraps the app and is there so that the entrypoint can be as simple as possible. The entrypoint's role is to create the Kernel and let it handle the request. The Kernel takes care of creating services, controllers, delegating the request to its handler and basically everything else (through delegation to other parts of the framework — for example the decision of which controller/action responds to a certain request is passed to a router).

Both of those live in the application and not the framework because the user might need to modify them and do something we (as the framework authors) did not anticipate. That being said, our framework must define some boilerplate the application can use (like the actual Kernel implementation for the application to extend) so it's as simple as possible. Ideally, this is what a very simple entrypoint looks like:

<?php

use The\App\Kernel;
use The\Framework\Http\Request;

require_once __DIR__ . '/../../vendor/autoload.php';

$kernel = new Kernel();
$request = Request::createFromGlobals();
$response = $kernel->handle($request);
$response->send();

The entrypoint lives inside app/public which is also what the document root is. The reason for it being hidden under the public dir is tied to security — when you point a webserver to a directory, all of its content (including child directories) becomes directly accessible through http. You can, of course, come up with rules and configurations to avoid direct access to php files and other clever tricks, but the easier solution is to simply not have the files in the document root at all. The public dir only contains files that are meant to be accessible directly, like our entrypoint or some static assets (images, javascripts, css and similar).

And this is what the application kernel should look like:

<?php

namespace The\App;

use The\Framework\Kernel as BaseKernel;

final class Kernel extends BaseKernel
{
}

In short — the default, simple case should be as simple as possible because all the complexity happens on the framework level.

Symfony currently uses a slightly different approach to the entrypoint — it uses a runtime where your entrypoint simply returns an instance of Kernel and the runtime takes care of that. While that's a smarter and better solution, it hides the bootstrapping so I decided to go the old-school manual way in this tutorial.

Step 2: Request & Response

Those are actually interconnected with the Kernel. In standard PHP you can write the response using the standard echo construct and send headers using the header() function which is everything you need to send a http response. But it's not a great way to go about it — you end up writing random headers in the middle of a controller, echoing inside a service, etc. So frameworks have decided to abstract all this into two classes: Request and Response.

You might be asking why this abstraction is needed at all. The reason is because of the "separation of concerns" principle — in PHP, you can access superglobals from anywhere. So if you want to check what a GET parameter's value is inside a html template you can do that (which doesn't mean you should). If you need that same parameter from a Request object, you have to consciously pass it from a controller to a template which makes the contract explicit instead of implicit.

Same goes for Response — you could just echo inside a template, you could send headers as part of a controller (or even a service) but then you're leaking concerns left and right and you create an unmaintainable mess pretty quickly. When you isolate all output data into a Response object, you make sure that everyone uses it and nothing else for output.

Request parses raw PHP data (like $_SERVER, $_POST, $_GET and others) into a nice object and Response acts as a single place for all your headers and body content to live in until it's eventually shown to the user of the app.

The Request needs to know everything about the user input:

  • scheme
  • host
  • path
  • query
  • body
  • headers
  • cookies
  • and others

For now, let's define the request as a simple object with the following properties:

final readonly class Request
{
    /**
     * @param array<string, string> $query
     * @param array<string, string> $headers
     * @param array<string, string> $cookies
     */
    public function __construct(
        public string $scheme,
        public string $host,
        public string $path,
        public string $method,
        public ?string $body = null,
        public array $query = [],
        public array $headers = [],
        public array $cookies = [],
        public array $files = [],
    ) {
    }
}

It hosts all the things I mentioned (and more). Most of it is pretty self-explanatory, some things worth noting:

  • files are not typehinted, we'll eventually build an abstraction over them but it's out of scope for now.
  • $headers are typehinted as array<string, string>, even though correctly it should be array<string, array<string>> — it's valid to send the same header multiple times. We'll just skip this for our implementation.

PHP provides us with all that information, but we need to parse it out of PHP's superglobals:

    public static function createFromGlobals(): self
    {
        $isHttps = isset($_SERVER['HTTPS']) && $_SERVER['HTTPS'];
        $scheme = $isHttps ? 'https' : 'http';
        $body = file_get_contents('php://input') ?: null;

        try {
            [, $files] = request_parse_body();
        } catch (RequestParseBodyException) {
            $files = $_FILES;
        }

        $requestUri = $_SERVER['REQUEST_URI'];
        $path = parse_url($requestUri, PHP_URL_PATH);

        return new self(
            scheme: $scheme,
            host: $_SERVER['HTTP_HOST'],
            path: $path,
            method: $_SERVER['REQUEST_METHOD'],
            body: $body,
            query: $_GET,
            headers: getallheaders(),
            cookies: $_COOKIE,
            files: $files,
        );
    }

Notes on implementation:

  • detecting whether we're using https is by checking whether the $_SERVER array contains the 'HTTPS' key with a non-empty value, based on that we decide whether we're on https or http
  • $body must be populated before calling request_parse_body() — it consumes the value and it would not be available
  • request_parse_body() makes it possible to get the $_POST and $_FILES array even for requests that are not of the method POST (e.g. PUT, PATCH), we gracefully fall back to providing $files as the original $_FILES array.
  • PHP doesn't provide us with the path alone (it includes query string as well) so we need a little help of the parse_url() function
  • we use getallheaders(), that function might not be available under all environments, but should be in all of the important ones (FPM, Apache, FrankenPHP)

With that out of the way, two of our four entrypoint lines now work!

$kernel = new Kernel(); // works
$request = Request::createFromGlobals(); // works
$response = $kernel->handle($request); // does not work
$response->send(); // does not work

For this part in the series we'll skip line 3 and go straight to creating the response object.

Response

The response is what's returned to the user's browser and consists of the content (body), status code and the headers, it can be modeled like this:

final readonly class Response
{
    /**
     * @param array<string, string> $headers
     */
    public function __construct(
        public string $body,
        public StatusCode $statusCode = StatusCode::OK,
        public string $contentType = 'text/html',
        public array $headers = [],
    ) {
    }
}

Some notes:

  • I decided to use an enum for the status code instead of a simple integer
  • content type does not have to be separate but it's such a common header that I decided it's worth having a separate name
  • headers are a direct access array for now but in later parts we'll be hiding the array behind methods because the keys need to be case insensitive

Now that we have the Response object, we need to actually send it. From the entrypoint we can see that it uses $response->send() — let's define it!

    public function send(): void
    {
        $this->sendHeaders();
        $this->sendBody();
    }

I'm splitting it into two, let's start with the simpler one:

    private function sendBody(): void
    {
        echo $this->body;
    }

Yep, as easy as that — just echo the content. Now for the headers:

    private function sendHeaders(): void
    {
        if (headers_sent($filename, $line)) {
            throw new HeadersAlreadySentException("Cannot send headers, the headers have been already sent in {$filename} on line {$line}");
        }

        http_response_code($this->statusCode->value);
        foreach ($this->headers as $name => $value) {
            header("{$name}: {$value}");
        }
        header("Content-Type: {$this->contentType}");
    }

First we check whether headers have been already sent and if so, we throw an exception. Outside manually sending headers using the header() function, this also happens when you (accidentally) echo or var_dump() or write output in any other way. Then we send the status code and headers and we're done.

Step 3: Wiring It Up

Right now the code doesn't run because the handle() method of the Kernel does not exist. We won't be implementing it properly just yet, but looking at the contract we can see that from the outside point of view it simply takes a request and returns a response. So if our goal is to just make the code run (which it is for now), we can easily fake it!

Inside the framework Kernel we add this:

    public function handle(Request $request): Response
    {
        return new Response(
            body: '<strong>Hello</strong> <em>world</em>!',
        );
    }

And now all our code is perfectly valid, if a little simplistic for a framework!

When I run the server (for example by running cd app/public && php -S 127.0.0.1:8000 from the root of the project) and open my browser at http://127.0.0.1/, I can see a beautiful:

We can be a little fancier and actually make use of the request object!

    public function handle(Request $request): Response
    {
        $name = $request->query['name'] ?? 'world';

        return new Response(
            body: "<strong>Hello</strong> <em>{$name}</em>!",
        );
    }

We default to "world" if no name GET parameter exists, but if it does, we show the name instead. And sure, navigating to http://127.0.0.1:8000/?name=Dominik shows "Hello, Dominik!" instead.


Summary of changes

It might seem we accomplished little but we already did something very important: We built abstractions for the HTTP request and response and established the basic structure of the lifecycle of our app. You can already see that having the structure separated like this, we could simply provide a fake handle() method without changing any of the other code which is exactly why abstractions like these exist.

The next part will be smaller and will consist of a little bit of tidying up the request and response object (let's call it part 1.5) while the part after that will focus on creating controllers and routing. And maybe we'll even handle the XSS vulnerability we introduced!


Side note: Feel free to offer ideas for our awesome framework's name cause right now it's just called "The" framework which is not ideal. On the bright side, this would mean someone finally managed to find a name that's even harder to search for than Go!

View original on chrastecky.dev
1

Making Your Angular App SEO-Friendly with SSR

SSR support in Angular makes it suitable for a whole new category of apps: content websites that need SEO and other metadata rendered directly in the HTML source instead of client-side at runtime.

And the best part is that most of the time you don’t need to do anything special!

Changing the title

You’ve most likely done this already, and SSR doesn’t change a thing. You still do it the same way you always did: inject the Title service from Angular and set the title:

import {Title} from "@angular/platform-browser";

// then in ngOnInit or somewhere like that

this.title.setTitle('Cool New Title');

And that’s it — it works automatically both client-side and server-side.

Changing meta tags

Changing meta tags follows the same principle — Angular has a service for exactly that. Unsurprisingly, it’s simply called Meta, and you can import it from @angular/platform-browser:

import {Meta} from "@angular/platform-browser";

Then you can create a meta tag:

this.meta.addTag({
  name: 'description',
  content: 'This is a description',
});

Or use a property instead of a name:

this.meta.addTag({
  property: 'og:title',
  content: 'Cool New Title',
});

This approach, however, has a small caveat: all tags are added one by one as you navigate through the pages, which means you end up with multiple duplicate descriptions or og:title tags. That would be fine if all search bots were server-side only, but some (like Googlebot) actually render client-side JavaScript, and they could get confused. Instead, you can first search for the tag and create it only if it doesn’t exist:

let description = this.meta.getTag("name='description'");
if (!description) {
  description = this.meta.addTag({
    name: 'description',
  });
}
description!.content = 'This is a description';

The syntax for getTag() is attributeName='attribute value', so you can search by pretty much anything. While there should only be one description tag, there might be other tags with multiple instances, and then searching by name is not going to help. In that case, when creating the tag, you can assign a unique ID or another attribute that makes it easy to look up later.

Changing head link tags

The most common SEO use case here is setting the canonical URL. Another common one is linking to an alternate language or version.

This time we don’t get any ready-made Angular service, but it’s easy enough to handle by appending elements directly to the DOM. However, we won’t use the window.document browser API (because it doesn’t exist on the server), but an Angular wrapper instead. You must inject it using an injection token called DOCUMENT:

import {DOCUMENT} from '@angular/core';
// if using constructor injection, also import Inject
import {Inject} from '@angular/core';
// if using the inject function, import it
import {inject} from '@angular/core';

// then inject it in some way
private readonly document = inject(DOCUMENT);

// or constructor injection
constructor(
  @Inject(DOCUMENT) private readonly document: Document,
) {
}

After getting a reference to the document object, you can use it the same way as you would use the native window.document object:

const head = this.document.getElementsByTagName('head')[0];
let element: HTMLLinkElement | null = this.document.querySelector(`link[rel='canonical']`) || null;
if (element == null) {
  element = this.document.createElement('link') as HTMLLinkElement;
  element.rel = 'canonical';
  head.appendChild(element);
}
element.href = 'https://example.com/';

The above code does the following:

  • gets a reference to the <head> tag

  • checks whether a <link> element with rel="canonical" exists

    • if not, it creates one
  • assigns https://example.com/ as the canonical link

That way, when you navigate to a different page, the existing canonical link gets updated instead of creating another one.

Getting the current URL

Manually assigning the URL doesn’t make much sense. Instead, you want to get the current URL and normalize it in some way. Here the client side differs slightly from the server side, and you need a solution specific to the server side. For canonical links you can even skip the client side entirely, but I’ll include it anyway.

To modify server-side behavior, you need to inject a Request object with the help of the REQUEST injection token:

You can of course use the inject() function if that’s your thing. From now on I’ll be using constructor injection only, but the inject() function can be used interchangeably with it.

import {Inject, Optional, REQUEST} from '@angular/core';

constructor(
  @Optional() @Inject(REQUEST) private readonly request: Request | null,
) {
}

The above only works if you use the AngularNodeAppEngine in your server.ts. If your app is older and was bootstrapped with the CommonEngine, you need to provide the request object manually.

Additionally, you need to detect whether you’re on the server or in the browser, so inject the platform ID:

import {Inject, PLATFORM_ID} from '@angular/core';

  constructor(
    @Inject(PLATFORM_ID) private platformId: Object,
  ) {
  }

Then replace the example.com assignment from above with the following:

if (isPlatformServer(this.platformId) && this.request) {
  const url = new URL(this.request.url);
  element.href = url.toString();
}

This first makes sure the code only runs when you’re on the server and the request object is not null (which it shouldn’t be if you’re on the server, but if you need to write generic code that should work across various environments, it’s better to be safe).

Then it creates a new URL object and assigns the canonical URL to its value. Why wrap it in a URL object? Because we’re going to do some normalization!

Before assigning the URL to the canonical link element, you might want to remove some marketing query parameters:

const url = new URL(this.request.url);

const bannedParams = ['utm_medium', 'utm_source', 'utm_content', 'fbclid']; // add more if you want
for (const param of bannedParams) {
  if (url.searchParams.has(param)) {
    url.searchParams.delete(param);
  }
}

element.href = url.toString();

Or you might want to make sure your canonical hostname is used:

if (url.host === 'www.chrastecky.dev') {
  url.host = 'chrastecky.dev';
}

Or any other combination of rules to make your URL the canonical version.

CommonEngine

If you’re not yet using the new(ish) AngularNodeAppEngine, you’re probably using the CommonEngine, which doesn’t include the request and response objects. Your server.ts should contain a call to commonEngine.render(), which should already include some providers in the providers array. Simply add new ones there:

commonEngine.render({
  // other parameters
  providers: [
    // other providers
    {provide: REQUEST, useValue: createWebRequestFromNodeRequest(req)},
  ],
})

Getting the URL client-side

If you want to update the canonical link client-side for any reason, you can subscribe to the router events like this:

if (isPlatformBrowser(this.platformId)) {
  this.router.events.subscribe(event => {
    if (event instanceof NavigationEnd) {
      const canonicalUrl = new URL(`https://${window.location.host}/${event.urlAfterRedirects}`);
      // todo update the link element
    }
  });
}

The above subscribes to all router events and, when it’s a NavigationEnd event, it checks what the URL after redirects is. Note that urlAfterRedirects only contains the path + query + fragment, not the whole URL, so you need to get the hostname from somewhere. Since we already checked that we’re in a browser, it’s safe to get it from window.location.

You could even mostly use this on the server side as well, but currently there is a problem with query parameters — the above code simply doesn’t provide them on the server side (I’m not sure whether that’s intended or a bug).

Returning correct status codes

By default, Angular always returns the status code 200, which means “OK”, and that’s not ideal. For example, if I link to a nonsense page (like https://chrastecky.dev/this-page-does-not-exist), it’s not enough that it shows an error — search engines need to actually see the 404 status code so they know not to index it.

Another great use case might be creating an HTTP interceptor for your API that automatically checks when the API returns 502/503 and returns the same status code, so that bots crawling your site know there’s a temporary hiccup and they should try again later.

If you’re using CommonEngine, skip the following section — the approach is a little bit different (more on that below).

First, inject the ResponseInit object:

import {Inject, RESPONSE_INIT} from '@angular/core';

constructor(
  @Inject(RESPONSE_INIT) private readonly responseInit: ResponseInit | null,
) {
}

Then make sure it’s present and you’re on the server before setting the status code:

if (isPlatformServer(this.platformId) && this.responseInit) {
  this.responseInit.status = 404;
}

Status codes in CommonEngine

In CommonEngine you need to provide the whole Response object instead of ResponseInit. To do so, add a new line to the server.ts providers:

commonEngine.render({
  // other parameters
  providers: [
    // other providers
    { provide: RESPONSE, useValue: res },
  ],
})

You will quickly notice that the RESPONSE injection token doesn’t actually exist, so we have to provide our own. You can put this anywhere you like; I usually have a single injection-tokens.ts file:

export const RESPONSE = new InjectionToken<Response | null>('RESPONSE');

And then inject it as usual:

constructor(
  @Optional() @Inject(RESPONSE) private readonly response: Response | null,
) {}

Finally, set the correct status code:

if (isPlatformServer(this.platformId) && this.response) {
  this.response.status(404)
}

Redirects

Redirects are an important part of SEO because they tell crawlers that a page has moved somewhere else. In principle, they’re the same as the 404 responses above, and you could implement a redirect like that in a component, but you can also do it without involving the Angular runtime at all — by adding a new route handler directly in server.ts. I’ll be using a static config in this example, but you can obtain it any way you like.

The config

It’s pretty simple:

export type RedirectStatus = 301 | 302;

export interface RedirectRule {
  from: string;
  to: string;
  status?: RedirectStatus;
}

export const redirectRules: RedirectRule[] = [
  { from: '/old1', to: '/new1', status: 301 },
  { from: '/old2', to: '/new2', status: 301 },
  { from: '/old3', to: '/new3', status: 301 },
];

You can put this anywhere you like, for example in a new redirects.ts file.

Redirecting

Somewhere close to the top, add a new route handler. This example is the same for both Angular engines because they both use an Express server, and this part kicks in before either of the two engines runs — meaning before any part of Angular is executed:

app.get('*', (req, res, next) => {
  const requestPath = req.path;
  const rule = redirectRules.find((item) => item.from === requestPath);
  if (!rule) {
    next();
    return;
  }
  const status = rule.status ?? 302;
  res.redirect(status, rule.to);
});

This is a classic middleware pattern where you get the request object, the response object, and a next handler. Step by step, this code:

  • registers a GET handler for all routes
  • checks whether any of the redirect rules matches the request path
  • if none matches, calls next() and does nothing else
  • otherwise, sets a redirect on the response using the status code and URL from the config

Conclusion

There are obviously many more important SEO topics, but the basic principles are mostly covered here. For example, adding structured data is just a variation of adding the canonical URL, except you add a <script> tag instead of a <link> one.

For me personally, the addition of SSR made it possible to use Angular pretty much everywhere, including the very blog site I’m writing this on. How about you — do you see yourself building your next content-heavy website in Angular?

View original on chrastecky.dev
2

Fun with PHP: Changing Readonly Properties and Other Shenanigans

Changing a Readonly Property

So, you know how readonly properties are, well… read-only? Turns out they’re not!

I stumbled upon this mechanism just as PHP started deprecating it — but hey, if you ignore the deprecation warnings, you can still use it up until PHP 9!

A bit of theory first: readonly properties can only be assigned inside a class constructor. After that, they’re supposed to be immutable.

Readonly properties can actually be set anywhere in the class and since 8.4 from anywhere. 

final readonly class ReadonlyClass
{
    public string $someProp;

    public function __construct()
    {
        $this->someProp = 'unchangeable!';
    }
}

The only official way to set such a property outside the class (pre 8.4) is via reflection — but even then, only if the property hasn’t been initialized yet:

final readonly class ReadonlyClass
{
    public string $someProp;
}
$test = new ReadonlyClass();
$reflection = new ReflectionClass(ReadonlyClass::class)->getProperty('someProp');
$reflection->setValue($test, 'changed once!');
var_dump($test->someProp);
$reflection->setValue($test, 'changed twice?');

This produces the predictable result:

string(13) "changed once!"

Fatal error: Uncaught Error: Cannot modify readonly property ReadonlyClass::$someProp

You get the same error no matter whether you do it in a constructor, set it in a different method or simply use reflection. As soon as you change the value in any official way more than once, you get an error.

Changing It Multiple Times

Enough stalling — let’s dive in! The magical object that can modify a readonly property (and much more) is ArrayObject.

Normally, you’d use ArrayObject to wrap an array. But it also accepts any object as the backing value — and that’s where the fun begins. Once you know how PHP stores properties internally (which is actually pretty simple), chaos follows.

Let’s start with this class:

final readonly class ReadonlyClass
{
    public string $someProp;
    private string $somePrivateProp;
    protected string $someProtectedProp;

    public function __construct()
    {
        $this->someProp = 'unchangeable?';
        $this->somePrivateProp = 'unchangeable?';
        $this->someProtectedProp = 'unchangeable?';
    }

    public function getSomePrivateProp(): string
    {
        return $this->somePrivateProp;
    }

    public function getSomeProtectedProp(): string
    {
        return $this->someProtectedProp;
    }
}

Now we create an instance and wrap it in an ArrayObject:

$instance = new ReadonlyClass();
$arrayObj = new ArrayObject($instance);

And now comes the fun part:

// simply use the property name for public properties
$arrayObj['someProp'] = 'changeable public!';
// use "\0[FQN]\0[Property name]" for private properties
$arrayObj["\0ReadonlyClass\0somePrivateProp"] = 'changeable private!';
// use "\0*\0[Property name]" for protected properties
$arrayObj["\0*\0someProtectedProp"] = 'changeable protected!';

var_dump($instance->someProp, $instance->getSomePrivateProp(), $instance->getSomeProtectedProp());

This prints:

string(18) "changeable public!"
string(19) "changeable private!"
string(21) "changeable protected!"

And just like that, you’ve changed an unchangeable property. You can modify it as many times as you want. So… what other arcane tricks are possible?

Changing an Enum Value

Enums are basically fancy objects that represent a specific named instance — optionally with a value. The key difference from old userland implementations is that PHP guarantees every enum case is a unique instance that’s always equal to itself, no matter where it’s referenced from.

In other words, an enum is really just an object, and ->value or ->name are plain properties.

enum MyEnum: string {
    case A = 'a';
    case B = 'b';
}

$arrayObj = new ArrayObject(MyEnum::A);
$arrayObj['value'] = 'b';
$arrayObj['name'] = 'C';

var_dump(MyEnum::A->value);
var_dump(MyEnum::A->name);

This prints exactly what you’d expect after reading the previous example:

string(1) "b"
string(1) "C"

Even more amusing: Running var_dump(MyEnum::A); now prints enum(MyEnum::C).

It won’t actually make it equal to another enum case, but if you use the value somewhere and reconstruct it using MyEnum::from(), you’ll get back MyEnum::B.

If you try to serialize and deserialize it, you’ll get an error — because MyEnum::C doesn’t exist:

var_dump(MyEnum::from(MyEnum::A->value));
var_dump(unserialize(serialize(MyEnum::A)));

The first prints enum(MyEnum::B), while the second throws a warning: Undefined constant MyEnum::C.

Breaking Types

ArrayObject is so powerful that even the type system trembles before it. Types? Mere suggestions!

final class TestTypedClass
{
    public string $str = 'test';
    public bool $bool = true;
    public int $int = 42;
}

$instance = new TestTypedClass();
$arrayObj = new ArrayObject($instance);

$arrayObj['str'] = 5;
$arrayObj['bool'] = 'hello';
$arrayObj['int'] = new stdClass();

var_dump($instance->str, $instance->bool, $instance->int);

Output:

int(5)
string(5) "hello"
object(stdClass)#3 (0) {
}

So if you ever thought “Hmm, this boolean could really use more than two possible values” — now you know how!

Dynamic Properties Everywhere

Some internal classes like Closure, Generator, and DateTime disallow dynamic properties. Nevermore!

$closure = fn () => true;
$arrayObject = new ArrayObject($closure);
$arrayObject['test'] = 'hello';

var_dump($closure->test);
// prints string(5) "hello"

Crashing PHP

And finally — my favourite one! Ever wanted to cause a segmentation fault? Try this:

$exception = new Exception("Hello there!");
$arrayObject = new ArrayObject($exception);
$arrayObject["\0Exception\0trace"] = -1;

var_dump($exception->getTraceAsString());

That gave me one beautiful Segmentation fault (core dumped)!

So, how did you like these all-powerful ArrayObject shenanigans?

View original on chrastecky.dev
1

New in PHP 8.5: Small Features, Big Impact

Originally, I intended to write an article about every single change in PHP 8.5, but then I realized that some of them don’t really warrant a full post. That didn’t stop me from trying, though.

Anyway, I’m older and wiser now (it’s been whole three months, after all), so here’s a single article covering the rest of the new features — the ones that might not justify a deep dive but are still worth knowing about.

OPcache Is Now a Mandatory Part of PHP

What many developers might not realize is that OPcache has been optional for the past decade, even though you’d be hard-pressed to find a production (or even development) server running PHP without it. That’s changing in PHP 8.5 — OPcache is now officially part of PHP itself and will no longer be bundled as a separate extension.

Final Property Promotion

I’ve already covered this in a separate article, but to summarize: promoted properties (the ones defined in a constructor’s parameter list) can now be declared final.

Attributes on Constants

Non-class compile-time constants (those declared with const, not define()) can now have attributes. Alongside this comes a new Attribute::TARGET_CONSTANT target and a new ReflectionConstant::getAttributes() method. You can also now use the built-in #[Deprecated] attribute on constants.

Asymmetric Visibility for Static Properties

This brings static properties in line with instance properties in terms of visibility. The same asymmetric-visibility rules now apply to both.

Easier Access to Error and Exception Handlers

Until now, PHP only provided setter functions for error and exception handlers, which returned the previous handler. To retrieve the current handler, developers had to resort to a small hack like this:

$currentHandler = set_error_handler('must_be_a_valid_callable');
restore_error_handler();

In PHP 8.5, you can use the new get_error_handler() and get_exception_handler() functions instead. Both return exactly the same value that was originally passed to their respective setter, with a return type of ?callable.

New array_first() and array_last() Functions

No more $array[array_key_first($array)] or, worse, reset($array)! These two new functions complement array_key_first() and array_key_last() introduced in PHP 7.3. They both return null for empty arrays.

A Saner Directory Class

The Directory class (returned, for example, by the dir() function) is what’s known as a resource object — a class-like wrapper for what used to be old-style resource types.

These resource objects typically can’t be instantiated with new, serialized, or cloned, and generally don’t behave like regular classes. The Directory class was the odd one out — it allowed all of that for historical reasons, though doing so never resulted in a valid instance, and directories created that way couldn’t actually be used.

In PHP 8.5, Directory joins the rest of its siblings and becomes a proper resource object, behaving consistently with the others.

#[Override] Can Now Apply to Properties

Just as you could previously mark methods that override a parent’s implementation, you can now apply #[Override] to properties. As with methods, PHP will throw an error if the property doesn’t actually override anything.

#[Deprecated] Can Be Used on Traits

This allows you to deprecate an entire trait. Whenever a class uses a deprecated trait, PHP will emit a deprecation notice.

Deprecations

As usual, PHP 8.5 brings a few deprecations. Besides some more obscure ones (did you know you could use a semicolon instead of a colon in a case statement?), here are the ones more likely to affect real-world codebases:

  • Backtick operator: The backtick operator is now deprecated. If you’re unfamiliar, it’s a shorthand for shell_exec() — for example, echo whoami; is equivalent to echo shell_exec('whoami');. Some older codebases still use it, so keep an eye out for that.
  • __sleep() and __wakeup(): Following the deprecation of the Serializable interface, PHP is now deprecating these legacy serialization hooks as well. Going forward, use __serialize() and __unserialize() instead.
  • setAccessible() in Reflection: These methods have done nothing since PHP 8.1 but remained for backward compatibility. In 8.5, they’ll now trigger a deprecation notice.
  • SplObjectStorage methods: The contains(), attach(), and detach() methods are deprecated. Use the ArrayAccess equivalents — offsetExists(), offsetSet(), and offsetUnset() — instead.
View original on chrastecky.dev
1

New in PHP 8.5: Closures as Constant Expressions

Ever wanted to set a closure as a default parameter value in PHP, only having to come up with workarounds? In PHP 8.5, that frustration is gone. Closures can now be constant expressions — meaning they work anywhere you’d use a literal value.

I’ve been bitten by this limitation before. Many times. Now, you can use closures in places where you could previously only use values like integers or strings:

  • Default parameter values
  • Constant values
  • Property default values
  • Attribute parameter values
  • And more

Default values

In the past, I’ve written code like this:

function someFunction(mixed $someValue, ?callable $callback = null): bool
{
    $callback ??= fn () => true;
    return $callback($someValue);
}

Or this:

final class SomeClass
{
    private Closure $someCallable;

    public function __construct()
    {
        $this->someCallable = function (mixed $value): bool {
            // todo
            return true;
        };
    }
}

With closures now being constant expressions, both examples can be simplified to:

function someFunction(
    mixed $someValue,
    callable $callback = static function () { return true; },
): bool {
    return $callback($someValue);
}

final class SomeClass
{
    private Closure $someCallable = static function (mixed $value): bool {
        // todo
        return true;
    };
}

No more $callback ??= gymnastics. Using closures directly as default parameter values is something I do fairly often, so being able to tighten the public interface by avoiding nonsense values like null is a great improvement.

Attributes

This is another great change — you can now define functions directly within attributes. For example:

#[Attribute(Attribute::TARGET_PROPERTY)]
final readonly class TruthyValidator
{
    public function __construct(
        public Closure $truthyValidator = static function(mixed $value): bool {
            return (bool) $value;
        }
    ) {
    }
}

Here’s a simple validator attribute that checks whether the value is truthy, with the default implementation just casting it to a boolean and letting PHP handle the conversion. But say you want to consider the string '0' as truthy:

    #[TruthyValidator(truthyValidator: static function(string|int|null $value): bool {
        return $value === '0' || $value;
    })]
    public string|int|null $someProperty = null;

First-Class Callables

This is technically a separate RFC, but it was split for voting reasons rather than technical ones, so I’m covering both in the same article.

In addition to standard closures where you define the function body inline, you can now also use first-class callables as constant expressions. This means all of the above examples also work with them.

<?php

// define a default validator
function defaultValidatorFunction(mixed $value): bool
{
    return (bool) $value;
}

// define the validator class
#[Attribute(Attribute::TARGET_PROPERTY)]
final readonly class TruthyValidator
{
    public function __construct(
        // and assign the default validator using the first-class callable syntax
        public Closure $truthyValidator = defaultValidatorFunction(...),
    ) {
    }
}

// define our custom validation function
function truthyValidatorWithoutZeroString(string|int|null $value): bool
{
    return $value === '0' || $value;
}

class SomeClassToBeValidated
{
    // and use it as a first-class callable
    #[TruthyValidator(truthyValidator: truthyValidatorWithoutZeroString(...))]
    public string|int|null $someProperty = null;
}

Conclusion

I really like this addition because it — like many other recent improvements — moves PHP toward a cleaner, more consistent language with fewer hacks and a saner syntax.

Where will you use this first? Drop your examples in the comments — I’m curious what creative cases you come up with.

View original on chrastecky.dev
3

New in PHP 8.5: The Pipe Operator

One of PHP's longstanding limitations is that scalar values (strings, integers, arrays, etc.) cannot have methods. As a result, deeply nested operations often end up looking cluttered and hard to read:

echo ucfirst(strtolower(preg_replace('@\s+@', '', "HELLO THERE!")));

There are a few workarounds, such as using intermediate variables:

$temp = "HELLO THERE!";
$temp = preg_replace('@\s+@', '', $temp);
$temp = strtolower($temp);
$temp = ucfirst($temp);

echo $temp;

Or using indentation to improve readability:

echo ucfirst(
    strtolower(
        preg_replace(
            '@\s+@',
            '',
            "HELLO THERE!"
        ),
    ),
);

These approaches work, but they're clunky. The new pipe operator offers a more elegant solution by allowing you to chain the result of the left-hand expression into a callable on the right:

echo "HELLO THERE!"
    |> fn (string $str) => preg_replace('@\s+@', '', $str)
    |> strtolower(...)
    |> ucfirst(...)
;

If you've ever worked with fluent setters, this pattern should feel very familiar.

How It Works

The pipe operator passes the result of the left-hand expression as an argument to the callable on the right. That callable must accept exactly one required parameter. It pairs perfectly with first-class callables (ucfirst(...)) and short arrow functions (fn($x) => $x).

It’s a full expression, so you can use it wherever any expression is allowed—assignments, return statements, conditionals, etc.

Operator Precedence

The pipe operator is left-associative, just like most arithmetic operators. That means expressions are evaluated from left to right:

// correctly evaluates to 2
$result = 2 + 2 |> sqrt(...);

// equivalent to this expression with parentheses
$result = (2 + 2) |> sqrt(...);

You can, of course, use parentheses to alter evaluation order:

// will evaluate to something like 3.4142135623731
$result = 2 + (2 |> sqrt(...));

// equivalent to
$result = 2 + sqrt(2);

The pipe operator has higher precedence than comparison operators, but lower than arithmetic ones. So:

$result = 2 + 2 |> sqrt(...) > 5;
// is equivalent to
$result = ((2 + 2) |> sqrt(...)) > 5
// is equivalent to
$result = sqrt(2 + 2) > 5;

The rules are intuitive, but one case where parentheses are often necessary is with the null coalescing operator:

$result = 5 |> trueOrNullFunction(...) ?? false;
// equivalent to
$result = (5 |> trueOrNullFunction(...)) ?? false;
// equivalent to
$result = trueOrNullFunction(5) ?? false;

And if you're providing an optional callable, parentheses are required:

// this is wrong without parentheses
$result = 5 |> $possiblyNullCallable ?? fn ($x) => true;
// this is correct
$result = 5 |> ($possiblyNullCallable ?? fn ($x) => true);

Higher-Order Functions

The pipe operator really shines when used with higher-order functions—functions that return other functions:

function map(callable $mapper): Closure
{
    return fn (array $array) => array_map($mapper, $array);
}

function filter(callable $filter): Closure
{
    return fn (array $array) => array_filter($array, $filter);
}

// assume is_odd and pow2 exist
$result = [1, 2, 3, 4, 5]
    |> filter(is_odd(...))
    |> map(pow2(...))
;

Caveats

The pipe operator is highly optimized and introduces virtually no overhead compared to traditional function calls. However, it does have some limitations:

  • It only works with callables that accept exactly one required argument.
  • Callables that require additional arguments must be wrapped in a closure.
$result = [1, 2, 3]
    |> fn (array $array) => array_filter($array, fn (int $num) => $num % 2 === 0)
    |> fn (array $array) => array_map(fn (int $num) => $num ** 2, $array)
;

This adds a tiny performance cost, though it’s usually negligible. If the Partial Function Application RFC is accepted, it will make this kind of usage even cleaner.

p>One major limitation is that the pipe operator doesn’t support references. For example, the following code will throw an error:

function square(int &$number): void
{
    $number **= 2;
}

$num = 2;
$num |> square(...); // ❌ This will fail

Some functions in PHP can accept both references and values, depending on how they're called (something I didn't even know was possible before writing this article). These will work with the pipe operator, but the values will always be passed by value, not by reference.

Conclusion

The new pipe operator is a welcome addition to PHP 8.5 that makes functional-style programming much cleaner and more readable. Whether you're cleaning up strings, transforming arrays, or composing complex logic, it allows you to express intent without sacrificing clarity.

While it has a few limitations—such as lack of reference support and the need for wrappers around multi-argument functions—these are relatively minor compared to the readability gains. And with future features like partial function application potentially on the way, the story will only get better.

What do you think? Will the pipe operator find a place in your workflow, or do you prefer more traditional patterns?

View original on chrastecky.dev
1

Go Meets PHP: Enhancing Your PHP Applications with Go via FFI

As an interpreted language, PHP has inherent performance limitations, especially when it comes to CPU-bound tasks. Go, on the other hand, is a compiled language known for its speed and efficiency. By leveraging PHP’s Foreign Function Interface (FFI), we can call Go functions from PHP via a shared C layer and achieve significant performance improvements in the right scenarios.

Before We Start

There are a few caveats to keep in mind:

  • This approach only benefits CPU-bound tasks — it won’t help with I/O-bound operations like database queries or API calls.
  • FFI adds overhead. For simple tasks, PHP may still be faster despite Go’s raw speed.
  • We’re using Go’s C bindings, which add an extra layer. For the absolute best performance, writing in C directly is faster.
  • Cross-platform support can be tricky — you’ll need to compile your Go shared library separately for each target platform and architecture.
  • Memory management between PHP and Go requires care — you need to handle allocation and freeing of memory correctly on both sides.

That said, for the right use cases, this technique can be extremely powerful without the complexity of writing low-level C code.

Hello World!

No tutorial would be complete without a “Hello, World!” example — but let’s skip the static string and jump straight into a personalized greeting.

In Go, it’s as simple as:

package main

import "fmt"

func HelloWorld(name string) {
	fmt.Printf("Hello %s!\n", name)
}

Calling it is straightforward:

	HelloWorld("Dominik")

Which prints: Hello Dominik!

To make this callable from PHP, we’ll need to export it as a C function. Here's a basic binding:

package main

import "C"
import (
	"fmt"
)

//export HelloWorldC
func HelloWorldC(name *C.char) {
	result := C.GoString(name)
	fmt.Printf("Hello %s!\n", result)
}

However, mixing conversion and logic can get messy. A cleaner approach is to separate concerns:

package main

import "C"
import (
	"fmt"
)

//export HelloWorld
func HelloWorld(name *C.char) {
	HelloWorldGo(C.GoString(name))
}

func HelloWorldGo(name string) {
	fmt.Printf("Hello %s!\n", name)
}

func main() {}

Now we have a clear boundary: HelloWorld handles data conversion, and HelloWorldGo contains the business logic.

The //export comment is essential — without it, Go won’t export the function. You also need an empty main() function to satisfy the Go compiler when building shared libraries in the main package.

Build it with:

go build -buildmode=c-shared -o hello.so hello.go

This generates two files: hello.so and hello.h, both of which we’ll need on the PHP side.

Wiring It Up in PHP

Create an FFI instance in PHP:

<?php

$ffi = FFI::cdef(
    file_get_contents(__DIR__ . '/hello.h'),
    __DIR__ . '/hello.so',
);

However, PHP uses a non-standard C header parser, so we’ll need to trim hello.h to just this:

extern void HelloWorld(char* name);

Once that’s done, you can call it directly:

$ffi->HelloWorld("Dominik");

Which outputs: Hello Dominik!

The FFI Overhead

Before we dive deeper, let’s compare the performance of this FFI approach against a native PHP function. For simple functions like this, the FFI overhead is significant, and using Go wouldn’t make much sense.

Running the following code, we compare the performance of calling the Go function via FFI a thousand times versus calling a native PHP function:

<?php

$ffi = FFI::cdef(
    file_get_contents(__DIR__ . '/hello.h'),
    __DIR__ . '/hello.so',
);

function HelloWorld(string $name): void
{
    echo "Hello {$name}!", PHP_EOL;
}

$start = microtime(true);
for ($i = 0; $i < 1000; $i++) {
    $ffi->HelloWorld("Dominik");
}
$end = microtime(true);

$timeGo = $end - $start;

$start = microtime(true);
for ($i = 0; $i < 1000; $i++) {
    HelloWorld("Dominik");
}
$end = microtime(true);
$timePhp =  $end - $start;

echo "Go version took {$timeGo} seconds.", PHP_EOL;
echo "PHP version took {$timePhp} seconds.", PHP_EOL;

The results:

Go version took 0.51009082794189 seconds.
PHP version took 0.0016758441925049 seconds.

As you can see, the Go version is much slower here — over 300 times slower than native PHP. That’s not because Go is slow, but because FFI incurs a high cost per call. Each of those 1,000 calls crosses the PHP–C–Go boundary.

Now let’s move the loop inside Go to reduce the number of boundary crossings. Here’s the updated Go function:

func HelloWorldGo(name string) {
	for range 1000 {
		fmt.Printf("Hello, %s!\n", name)
	}
}

And an equivalent PHP function for fairness:

function HelloWorld(string $name): void
{
    for ($i = 0; $i < 1000; $i++) {
        echo "Hello {$name}!", PHP_EOL;
    }
}

The results now look very different:

Go version took 0.0031590461730957 seconds.
PHP version took 0.012860059738159 seconds.

This time, the Go version is clearly faster. Why? Because we’ve reduced the number of PHP–FFI–Go context switches from 1,000 down to just 1. This highlights the most important performance tip when using FFI: minimize the number of boundary crossings. Let Go do as much as possible once you’re there.

Fibonacci

Now that we’ve seen how performance improves with fewer context switches, let’s try something that’s inherently CPU-bound: calculating the nth number in the Fibonacci sequence. We’ll stick with a naive recursive implementation to keep things simple (and CPU-intensive).

Here’s the Go version:

//export Fibonacci
func Fibonacci(n C.int) C.int {
	return C.int(fibonacciGo(int(n)))
}

func fibonacciGo(n int) int {
	if n <= 1 {
		return n
	}
	return fibonacciGo(n-1) + fibonacciGo(n-2)
}

And here’s the equivalent PHP version:

function fibonacci(int $n): int
{
    if ($n <= 1) {
        return $n;
    }

    return fibonacci($n - 1) + fibonacci($n - 2);
}

To benchmark both implementations:

<?php

$ffi = FFI::cdef(
    file_get_contents(__DIR__ . '/hello.h'),
    __DIR__ . '/hello.so',
);

function fibonacci(int $n): int
{
    if ($n <= 1) {
        return $n;
    }

    return fibonacci($n - 1) + fibonacci($n - 2);
}

$start = microtime(true);
$result = $ffi->Fibonacci(35);
$end = microtime(true);
$time = $end - $start;

echo "Go result: {$result}. It took {$time} seconds to compute.", PHP_EOL;

$start = microtime(true);
$result = fibonacci(35);
$end = microtime(true);
$time = $end - $start;

echo "PHP result: {$result}. It took {$time} seconds to compute.", PHP_EOL;

The output:

Go result: 9227465. It took 0.041604042053223 seconds to compute.
PHP result: 9227465. It took 3.975930929184 seconds to compute.

Same result, but Go is almost 100 times faster. And the difference gets even more dramatic with larger inputs. Here’s what happens with fibonacci(40):

Go result: 102334155. It took 0.39231300354004 seconds to compute.
PHP result: 102334155. It took 44.720011949539 seconds to compute.

That’s nearly 45 seconds for PHP versus less than half a second for Go. It’s a striking example of why you’d want to offload compute-heavy tasks to Go via FFI.

Where It Makes Sense

Some potential real-world use cases:

  • Sorting large in-memory datasets
  • Matrix operations and other complex math
  • Cryptographic algorithms not natively supported by PHP (e.g., BLAKE3)
  • Custom sorters (e.g., geo distance, radix sort)
  • Compression formats unsupported by PHP extensions
  • Working with XLS files (via Go libraries)
  • Concurrent workloads

Concurrent Work

Let’s now explore one of Go’s major strengths: concurrency. As an example, imagine a user uploads multiple images and your application needs to generate thumbnails for them. We’ll simulate the image processing step using time.Sleep to represent a long-running operation.

Here’s a simplified image processing function in Go:

func ResizeImage(path string) error {
	time.Sleep(300 * time.Millisecond)

	if rand.Int()%2 == 0 {
		return errors.New("test")
	}

	return nil
}

In Go, returning an error is a common idiom. Returning nil (similar to null in other languages) indicates success.

Now let’s look at the function we’ll be calling from PHP:

func ResizeImagesGo(paths []string) []string {
	var waitGroup sync.WaitGroup // create a wait group - once it's empty, everything has been processed
	var mutex sync.Mutex         // a mutex to safely write into the failed slice below
	failed := make([]string, 0)  // create a slice that can contain strings and has initial length of zero

	for _, path := range paths { // iterate over all paths
		path := path     // this recreates the path variable inside the current scope to avoid race conditions
		waitGroup.Add(1) // add one to the wait group
		go func() {      // run this in a goroutine (similar to threads in other languages)
			defer waitGroup.Done() // after this function finishes, waitGroup.Done() will be called
			err := ResizeImage(path)
			if err != nil { // if we have an error
				mutex.Lock()                  // lock the mutex to make sure only one goroutine is writing to the failed slice
				failed = append(failed, path) // add a new path to the list of failed paths
				mutex.Unlock()                // unlock the mutex so that any other goroutine can lock it again
			}
		}()
	}

	waitGroup.Wait() // wait until all wait groups are done

	return failed
}

I’ve commented the code heavily, but here’s the high-level flow:

  • Accept a list of image paths
  • Process each image in its own goroutine (like a lightweight thread)
  • Safely track which images failed using a mutex
  • Wait for all images to finish processing
  • Return the list of failed paths

Now comes the only messy part — the C binding. Unfortunately, that’s just how FFI works at this level:

//export ResizeImages
func ResizeImages(input **C.char, count C.int, failedOut ***C.char, failedCount *C.int) {
	// because this is a C binding and C doesn't have any nice structures built-in,
	// we have to pass the data as a char[] pointer and provide the count of items as
	// a second parameter

	// to avoid having to create a custom struct, we return the data by having them passed as references
	// the triple asterisk means it's a pointer to char array, the single asterisk means it's a pointer to
	// an integer

	paths := unsafe.Slice(input, int(count)) // we have to make a slice out of the input
	goPaths := make([]string, count)         // create a new Go slice with the correct length
	for i, path := range paths {
		goPaths[i] = C.GoString(path) // convert the C-strings to Go-strings
	}

	failed := ResizeImagesGo(goPaths) // call the Go function and assign the result

	// the parts below are some C-level shenanigans, basically you need to allocate (C.malloc) enough memory
	// to hold the amount of pointers that will be assigned, which is the length of the failed slice
	failedAmount := len(failed)
	ptrSize := unsafe.Sizeof(uintptr(0))
	cArray := C.malloc(C.size_t(failedAmount) * C.size_t(ptrSize))
	cStrs := unsafe.Slice((**C.char)(cArray), failedAmount)

	for i, str := range failed { // iterate over the failed paths
		cStrs[i] = C.CString(str) // and assign it to the C array
	}

	*failedOut = (**C.char)(cArray)    // assign the array to the reference input parameter
	*failedCount = C.int(failedAmount) // assign the count of failed items to the reference input parameter
}

Yes, it’s a bit messy — but that’s standard practice when working with low-level bindings in Go or C. The important part is that we’ve isolated the complexity into this layer. Imagine writing the actual business logic in C — suddenly Go feels a lot more pleasant.

Now, after rebuilding the library, you’ll need to update hello.h to include:

extern void ResizeImages(char** input, int count, char*** failedOut, int* failedCount);

PHP Integration

Let’s now call this function from PHP. Here’s the full example:

<?php

$ffi = FFI::cdef(
    file_get_contents(__DIR__ . '/hello.h'),
    __DIR__ . '/hello.so',
);

$imagePaths = [
    "pathA",
    "pathB",
    "pathC",
    "pathD",
];
$imagesCount = count($imagePaths);

$cArray = FFI::new("char*[" . count($imagePaths) . "]"); // create a new array with fixed size
$buffers = []; // this will just hold variables to prevent PHP's garbage collection

foreach ($imagePaths as $i => $path) {
    $size = strlen($path); // the size to allocate in bytes
    $buffer = FFI::new("char[" . ($size + 1) . "]"); // create a new C string of length +1 to add space for null terminator
    FFI::memcpy($buffer, $path, $size); // copy the content of $path to memory at $buffer with size $size
    $cArray[$i] = FFI::cast("char*", $buffer); // cast it to a C char*, aka a string
    $buffers[] = $buffer; // assigning it to the $buffers array ensures it doesn't go out of scope and PHP cannot garbage collect it
}

$failedOut = FFI::new("char**"); // create a string array in C, this will be passed as reference
$failedCount = FFI::new("int"); // create an integer which will be passed as reference

$start = microtime(true);
$ffi->ResizeImages(
    $cArray,
    count($imagePaths),
    FFI::addr($failedOut),
    FFI::addr($failedCount),
);
$end = microtime(true);
$time = $end - $start;

$count = $failedCount->cdata; // fetch the count of failed items

echo "Failed items: {$count}", PHP_EOL;
for ($i = 0; $i < $count; $i++) {
    echo " - ", FFI::string($failedOut[$i]), PHP_EOL; // cast each item to a php string and print it
}
echo "Processing took: {$time} seconds", PHP_EOL;

Depending on randomness, you’ll see output similar to:

Failed items: 4
 - pathA
 - pathC
 - pathD
 - pathB
Processing took: 0.30362796783447 seconds

Two things to notice:

  • The failed items are out of order — a clear sign the operations ran in parallel. Each image was processed in its own goroutine and reported failure as soon as it was done.
  • Total time is around 300 ms — the time it takes to process a single image, despite processing four at once. This shows we achieved true concurrency.

Memory Management

The previous example contains a memory leak — something you typically don’t have to worry about in PHP or Go, since both languages have garbage collectors. But once you introduce C into the mix, you’re responsible for manually managing memory.

Whether this matters depends on how you run your PHP code. If you use the traditional execute-and-die model (e.g. a web server spawns a PHP process that dies at the end of each request), then memory leaks are mostly harmless — the operating system will reclaim all memory when the process exits.

However, if you're using modern alternatives like RoadRunner, Swoole, AMPHP, ReactPHP, or any long-running PHP worker (Symfony Messenger), memory leaks will accumulate across requests and eventually exhaust system memory.

The rule of thumb is simple: if your C glue code allocates memory, you must free it once it’s no longer needed. In our case, both the outer array and the individual strings are allocated in Go:

// C.malloc is a direct allocation of memory
cArray := C.malloc(C.size_t(failedAmount) * C.size_t(ptrSize))

for i, str := range failed {
    // C.CString uses malloc in the background
	cStrs[i] = C.CString(str)
}

To free this memory in PHP, you can use FFI::free() directly:

echo "Failed items: {$count}", PHP_EOL;
for ($i = 0; $i < $count; $i++) {
    echo " - ", FFI::string($failedOut[$i]), PHP_EOL;
    FFI::free($failedOut[$i]); // free each string after use
}
FFI::free($failedOut); // finally free the array itself

Again, if you're only using short-lived PHP processes, this isn't a concern. But in long-running environments, proper memory management is essential to avoid leaks and unpredictable crashes.

Conclusion

The C-level glue code can be verbose and awkward, but once it’s in place, combining Go and PHP can unlock performance that’s hard to beat — all while keeping most of your code in modern, high-level languages.

What do you think? Would you consider using Go alongside PHP for performance-critical workloads?

View original on chrastecky.dev
1

New in PHP 8.5: Asymmetric Visibility for Static Properties

This minor addition brings asymmetric visibility—already available for instance properties—to static properties as well.

Previously, this was valid syntax:

 final class PublicPrivateSetClass {
    public private(set) string $instanceProperty;
}

As of PHP 8.5, you can now do the same with static properties:

 final class PublicPrivateSetClass {
    public private(set) static string $staticProperty;
}

While not the most groundbreaking feature, it improves consistency in the language—which is always a welcome change.

View original on chrastecky.dev
3

New in PHP 8.5: Final Promoted Properties

Starting with PHP 8.5, you'll be able to do the following:

 public function __construct(
    final public string $someProperty,
) {}

This wasn't possible before, as promoted properties couldn't be declared final.

Perhaps the more interesting part is that you can now omit the visibility modifier if you include final. In that case, the property will default to public:

 public function __construct(
    final string $someProperty, // this property will be public
) {}

Personally, I’m not a fan of this behavior — I prefer explicit over implicit. Fortunately, it can be enforced by third-party tools like code style fixers. Still, I would have preferred if the core required the visibility to be specified.

What do you think? Do you like this change, or would you have preferred a stricter approach?

View original on chrastecky.dev
7

New in PHP 8.5: Levenshtein Comparison for UTF-8 Strings

PHP has long had a levenshtein() function, but it comes with a significant limitation: it doesn’t support UTF-8.

If you’re not familiar with the Levenshtein distance, it’s a way to measure how different two strings are — by counting the minimum number of single-character edits (insertions, deletions, or substitutions) required to change one string into another.

For example, the following code returns 2 instead of the correct result, 1:

var_dump(levenshtein('göthe', 'gothe'));

There are workarounds — such as using a pure PHP implementation or converting strings to a custom single-byte encoding — but they come with downsides, like slower performance or non-standard behavior.

With the new grapheme_levenshtein() function in PHP 8.5, the code above now correctly returns 1.

Grapheme-Based Comparison

What makes this new function especially powerful is that it operates on graphemes, not bytes or code points. For instance, the character é (accented 'e') can be represented in two ways: as a single code point (U+00E9) or as a combination of the letter e (U+0065) and a combining accent (U+0301). In PHP, you can write these as:

$string1 = "\u{00e9}";
$string2 = "\u{0065}\u{0301}";

Even though these strings are technically different at the byte level, they represent the same grapheme. The new grapheme_levenshtein() function correctly recognizes this and returns 0 — meaning no difference.

This is particularly useful when working with complex scripts such as Japanese, Chinese, or Korean, where grapheme clusters play a bigger role than in Latin or Cyrillic alphabets.

Just for fun: what do you think the original levenshtein() function will return for the example above?

var_dump(levenshtein("\u{0065}\u{0301}", "\u{00e9}"));
View original on chrastecky.dev
14

New in PHP 8.5: Attributes on Constants

This change is quite straightforward, so this won’t be a long article. PHP 8.5 adds support for annotating non-class, compile-time constants with attributes. Compile-time constants are those defined using the const keyword, not the define() function.

Attributes can now include Attribute::TARGET_CONSTANT among their valid targets. Additionally, as the name suggests, Attribute::TARGET_ALL now includes constants as well. The ReflectionConstant class has been updated with a new method, getAttributes(), to support retrieving these annotations.

One particularly useful aspect of this change is that the built-in #[Deprecated] attribute can now be applied to compile-time constants.

As promised, this was a short post, since the change is relatively simple. See you next time—hopefully with a more exciting new feature in PHP 8.5!

View original on chrastecky.dev
4

New in PHP 8.5: Marking Return Values as Important

PHP 8.5 introduces a variety of compelling features. As a library author, I'm particularly thrilled by the addition of the built-in #[NoDiscard] attribute, enabling developers to mark a function or method's return value as important.

Tip: You can read the full RFC at wiki.php.net.

How does the NoDiscard attribute work?

Using #[NoDiscard] is straightforward—simply annotate your function or method. For example, marking a function that returns critical operation results, such as status flags or error messages, helps prevent accidental omission or unnoticed errors:

<?php

#[NoDiscard]
function processStuff(): array
{
    return [];
}

Now, if the function is called without using its return value, PHP generates the following warning:

The return value of function processStuff() should either be used or intentionally ignored by casting it as (void).

Customizing the Warning Message

You can provide a custom message for greater clarity:

<?php

#[NoDiscard("because this is a batch processing function, and if any of the items fail, it returns the error details in an array instead of throwing an exception.")]
function processStuff(): array
{
    return [];
}

This results in a more personalized warning:

The return value of function processStuff() is expected to be consumed, because this is a batch processing function, and if any of the items fail, it returns the error details in an array instead of throwing an exception.

Suppressing the Warning

Besides using the returned value (assigning or otherwise processing it), you can suppress this warning in several ways:

<?php

@processStuff();          // Error suppression operator
(void)processStuff();     // Explicit void cast
$_ = processStuff();      // If you're coming from Go ;)

However, beware of OPCache optimizations. If OPCache detects an unused instruction, it might optimize away the call, leading to inconsistencies between development and production environments.

<?php

(bool)processStuff(); // OPCache may ignore this because the result isn't used.

Using (void) explicitly is safe since OPCache will not optimize away explicit void casts.

Where is it used?

In addition to being usable in your own code, the #[NoDiscard] attribute is automatically applied to specific core PHP functions/methods, notably:

  • flock(): Ignoring its false return value can lead to difficult-to-diagnose concurrency issues.
  • Setters of DateTimeImmutable: These methods do not modify the original instance but return a new one. Ignoring this return value does nothing, a common pitfall for new developers.

When is the Warning Triggered?

PHP triggers the warning immediately before the function call executes, offering a significant safety benefit. If your application converts warnings into exceptions (as Symfony does by default), the potentially dangerous code never executes:

<?php

set_error_handler(function (int $errno, string $errstr, string $errfile, int $errline) {
    throw new ErrorException($errstr, 0, $errno, $errfile, $errline);
});

$file = fopen("/tmp/test.txt", "r+");
flock($file, LOCK_EX);
// Safe to write to the file! Or is it? We don't know because we ignored the return value of flock()
fwrite($file, "Hello world!");
fclose($file);

In this example, the flock() is never called! The warning prevents the execution of potentially harmful code, ensuring issues are caught early during development.

Constraints

The use of #[NoDiscard] comes with some logical constraints:

  • It cannot be applied to functions with a void or never return type, as enforcing usage of a non-existent return value doesn't make sense.
  • It cannot be used on property hooks (getters/setters), as reading a property inherently means you're working with its value. Ignoring it would lead to unnecessary confusion and complexity.

So, what do you think? I personally find the #[NoDiscard] attribute powerful and particularly valuable for library authors. I'm eagerly awaiting PHP 8.5 to incorporate this new feature into my own projects!

View original on chrastecky.dev
10

Creating a Simple Encrypted Matrix Bot in Go

I've created Matrix bots before, and sending simple unencrypted messages is so easy it doesn't even require a library. Typically, you'd get your room ID, username, and password, then perform two HTTP requests: one for login, and one for sending the message.

But recently, I wanted to do things the proper way. We're migrating from Slack to Matrix for a project I'm working on with some friends, and we've decided that all rooms, including our server notifications channel, should be encrypted. This meant I had to find a suitable library with end-to-end encryption support in a language I'm comfortable with. Eventually, I settled on mautrix-go.

Setting Up Your Matrix Bot

We'll create a straightforward proof-of-concept bot that logs in, sends a single message, and exits. Later, we'll enhance it by adding encryption support.

Installation

First, install the mautrix-go library:

go get maunium.net/go/mautrix

Defining Constants

We'll use some constants for simplicity in this example. Remember: never store sensitive credentials like this in production code.

const homeserver = "https://matrix.exapmle.com/" // replace with your server
const username = "test_bot"
const password = "super-secret-cool-password"
const roomID = "!okfsAqlvVqyZZRgPWy:example.com"

const userId = ""
const accessToken = ""
const deviceId = ""

Initially, the user ID, access token, and device ID are empty because the bot needs to log in and retrieve these values. Usually, you'd store them securely in a database or similar storage.

Initializing the Client

Now, let's create the Matrix client:

func main() {
	client, err := mautrix.NewClient(homeserver, userId, accessToken)
	if err != nil {
		panic(err)
	}
}

Logging In

If your credentials aren't set, log in to obtain them:

    if deviceId == "" || userId == "" || accessToken == "" {
		resp, err := client.Login(context.Background(), &mautrix.ReqLogin{
			Type: mautrix.AuthTypePassword,
			Identifier: mautrix.UserIdentifier{
				User: username,
				Type: mautrix.IdentifierTypeUser,
			},
			Password:         password,
			StoreCredentials: true,
		})
		if err != nil {
			panic(err)
		}

		log.Println(resp.DeviceID)
		log.Println(resp.AccessToken)
		log.Println(resp.UserID)

		return
	}

The printed values will look something like this:

2025/04/19 15:57:50 AQWFKLSBNJ
2025/04/19 15:57:50 syt_dgVzdF7ibFQ_GurkyhAWzEpTGgSBemjL_2JdxlO
2025/04/19 15:57:50 @test_bot:example.com

Copy these values back into your constants.

Sending an Unencrypted Message

Now we can send a basic message:

	client.DeviceID = deviceId
	content := event.MessageEventContent{
		MsgType: event.MsgText,
		Body:    "Hello world from Go!",
	}

	_, err = client.SendMessageEvent(context.Background(), roomID, event.EventMessage, content)
	if err != nil {
		panic(err)
	}

At this stage, your message will arrive in the Matrix room—but it's not encrypted yet:

Here's the full code so far:

import (
	"context"
	"log"
	"maunium.net/go/mautrix"
	"maunium.net/go/mautrix/event"
)

const homeserver = "https://matrix.exapmle.com/" // replace with your server
const username = "test_bot"
const password = "super-secret-cool-password"
const roomID = "!okfsAqlvVqyZZRgPWy:example.com"

const userId = "@test_bot:example.com"
const accessToken = "syt_dgVzdF7ibFQ_GurkyhAWzEpTGgSBemjL_2JdxlO"
const deviceId = "AQWFKLSBNJ"

func main() {
	client, err := mautrix.NewClient(homeserver, userId, accessToken)
	if err != nil {
		panic(err)
	}

	if deviceId == "" || userId == "" || accessToken == "" {
		resp, err := client.Login(context.Background(), &mautrix.ReqLogin{
			Type: mautrix.AuthTypePassword,
			Identifier: mautrix.UserIdentifier{
				User: username,
				Type: mautrix.IdentifierTypeUser,
			},
			Password:         password,
			StoreCredentials: true,
		})
		if err != nil {
			panic(err)
		}

		log.Println(resp.DeviceID)
		log.Println(resp.AccessToken)
		log.Println(resp.UserID)

		return
	}

	client.DeviceID = deviceId
	content := event.MessageEventContent{
		MsgType: event.MsgText,
		Body:    "Hello world from Go!",
	}

	_, err = client.SendMessageEvent(context.Background(), roomID, event.EventMessage, content)
	if err != nil {
		panic(err)
	}
}

Sending Encrypted Messages

Encrypting messages involves syncing with the server and setting up cryptography, but don't worry—it's still quite straightforward. Let's see how easily this can be done using mautrix-go.

Create a Cryptography Helper

We'll first create a secure key ("pickle key") and helper function. Make sure to keep this key completely secret and never share it publicly:

// note that the key doesn't have to be a string, you can directly generate random bytes and store them somewhere in a binary form
const pickleKeyString = "NnSHJguDSW7vtSshQJh2Yny4zQHc6Wyf"

func setupCryptoHelper(cli *mautrix.Client) (*cryptohelper.CryptoHelper, error) {
	// remember to use a secure key for the pickle key in production
	pickleKey := []byte(pickleKeyString)

	// this is a path to the SQLite database you will use to store various data about your bot
	dbPath := "crypto.db"

	helper, err := cryptohelper.NewCryptoHelper(cli, pickleKey, dbPath)
	if err != nil {
		return nil, err
	}

	// initialize the database and other stuff
	err = helper.Init(context.Background())
	if err != nil {
		return nil, err
	}

	return helper, nil
}

Syncing the Client

First, we create the syncer and assign it to the client:

	syncer := mautrix.NewDefaultSyncer()
	client.Syncer = syncer

Then we create and assign the crypto helper:

	cryptoHelper, err := setupCryptoHelper(client)
	if err != nil {
		panic(err)
	}
	client.Crypto = cryptoHelper

The syncer is needed to listen to events from synchronization, which is what we'll implement next:

	go func() {
		if err := client.Sync(); err != nil {
			panic(err)
		}
	}()

The Sync() method is a blocking call and runs until an error occurs, so we run it in a goroutine. Now we'll use a channel to wait for the first event from the syncer to make sure everything's initialized:

    readyChan := make(chan bool)
	var once sync.Once
	syncer.OnSync(func(ctx context.Context, resp *mautrix.RespSync, since string) bool {
		once.Do(func() {
			close(readyChan)
		})

		return true
	})

The sync.Once ensures the channel gets closed only once, even if multiple sync events come in in different threads. Finally, we wait for the first sync:

	log.Println("Waiting for sync to receive first event from the encrypted room...")
	<-readyChan
	log.Println("Sync received")

Now your client is ready to send encrypted messages! The full section we just created looks like this:

    readyChan := make(chan bool)
	var once sync.Once
	syncer.OnSync(func(ctx context.Context, resp *mautrix.RespSync, since string) bool {
		once.Do(func() {
			close(readyChan)
		})

		return true
	})

	go func() {
		if err := client.Sync(); err != nil {
			panic(err)
		}
	}()

	log.Println("Waiting for sync to receive first event from the encrypted room...")
	<-readyChan
	log.Println("Sync received")

And just to confirm everything worked, here's what the message looks like in the Matrix room:

As you can see, the message was encrypted successfully, but the session still isn't verified yet—hence the warning. We'll fix that next.

Here's the full source code so far:

import (
	"context"
	"log"
	"maunium.net/go/mautrix"
	"maunium.net/go/mautrix/crypto/cryptohelper"
	"maunium.net/go/mautrix/event"
	"sync"
)

const homeserver = "https://matrix.exapmle.com/" // replace with your server
const username = "test_bot"
const password = "super-secret-cool-password"
const roomID = "!okfsAqlvVqyZZRgPWy:example.com"

const userId = "@test_bot:example.com"
const accessToken = "syt_dgVzdF7ibFQ_GurkyhAWzEpTGgSBemjL_2JdxlO"
const deviceId = "AQWFKLSBNJ"
const pickleKeyString = "NnSHJguDSW7vtSshQJh2Yny4zQHc6Wyf"

func setupCryptoHelper(cli *mautrix.Client) (*cryptohelper.CryptoHelper, error) {
	// remember to use a secure key for the pickle key in production
	pickleKey := []byte(pickleKeyString)

	// this is a path to the SQLite database you will use to store various data about your bot
	dbPath := "crypto.db"

	helper, err := cryptohelper.NewCryptoHelper(cli, pickleKey, dbPath)
	if err != nil {
		return nil, err
	}

	// initialize the database and other stuff
	err = helper.Init(context.Background())
	if err != nil {
		return nil, err
	}

	return helper, nil
}

func main() {
	client, err := mautrix.NewClient(homeserver, userId, accessToken)
	if err != nil {
		panic(err)
	}

	if deviceId == "" || userId == "" || accessToken == "" {
		resp, err := client.Login(context.Background(), &mautrix.ReqLogin{
			Type: mautrix.AuthTypePassword,
			Identifier: mautrix.UserIdentifier{
				User: username,
				Type: mautrix.IdentifierTypeUser,
			},
			Password:         password,
			StoreCredentials: true,
		})
		if err != nil {
			panic(err)
		}

		log.Println(resp.DeviceID)
		log.Println(resp.AccessToken)
		log.Println(resp.UserID)

		return
	}
	client.DeviceID = deviceId

	syncer := mautrix.NewDefaultSyncer()
	client.Syncer = syncer

	cryptoHelper, err := setupCryptoHelper(client)
	if err != nil {
		panic(err)
	}
	client.Crypto = cryptoHelper

	readyChan := make(chan bool)
	var once sync.Once
	syncer.OnSync(func(ctx context.Context, resp *mautrix.RespSync, since string) bool {
		once.Do(func() {
			close(readyChan)
		})

		return true
	})

	go func() {
		if err := client.Sync(); err != nil {
			panic(err)
		}
	}()

	log.Println("Waiting for sync to receive first event from the encrypted room...")
	<-readyChan
	log.Println("Sync received")

	content := event.MessageEventContent{
		MsgType: event.MsgText,
		Body:    "Hello world from Go!",
	}

	_, err = client.SendMessageEvent(context.Background(), roomID, event.EventMessage, content)
	if err != nil {
		panic(err)
	}
}

Verifying the Session

For verified encryption, you'll need a recovery key (obtainable via Element). Store it securely. I have to admit, this part wasn't as intuitive for me—I had to look at some existing projects because it dives a bit deeper into Matrix internals than I usually go. Still, the method names are quite descriptive, so even without deep knowledge, it's not too hard to follow:

const recoveryKey = "EsUF NQce e4BW teUM Kf7W iZqD Nj3f 56qj GuN5 s3aw aut7 div2"

Just like the pickle key, the recovery key should be treated as highly sensitive—do not share or hardcode it in production environments.

Then, create this helper function:

func verifyWithRecoveryKey(machine *crypto.OlmMachine) (err error) {
	ctx := context.Background()

	keyId, keyData, err := machine.SSSS.GetDefaultKeyData(ctx)
	if err != nil {
		return
	}
	key, err := keyData.VerifyRecoveryKey(keyId, recoveryKey)
	if err != nil {
		return
	}
	err = machine.FetchCrossSigningKeysFromSSSS(ctx, key)
	if err != nil {
		return
	}
	err = machine.SignOwnDevice(ctx, machine.OwnIdentity())
	if err != nil {
		return
	}
	err = machine.SignOwnMasterKey(ctx)

	return
}

Call this function after synchronization—back in the main() function:

	err = verifyWithRecoveryKey(cryptoHelper.Machine())
	if err != nil {
		panic(err)
	}

Now, your messages will be encrypted, verified, and free of security warnings.

And just to confirm, here's what that looks like in the Matrix room—notice that the warning icon is gone:

Here's the full source code:

import (
	"context"
	"log"
	"maunium.net/go/mautrix"
	"maunium.net/go/mautrix/crypto"
	"maunium.net/go/mautrix/crypto/cryptohelper"
	"maunium.net/go/mautrix/event"
	"sync"
)

const homeserver = "https://matrix.exapmle.com/" // replace with your server
const username = "test_bot"
const password = "super-secret-cool-password"
const roomID = "!okfsAqlvVqyZZRgPWy:example.com"

const userId = "@test_bot:example.com"
const accessToken = "syt_dgVzdF7ibFQ_GurkyhAWzEpTGgSBemjL_2JdxlO"
const deviceId = "AQWFKLSBNJ"
const pickleKeyString = "NnSHJguDSW7vtSshQJh2Yny4zQHc6Wyf"

func setupCryptoHelper(cli *mautrix.Client) (*cryptohelper.CryptoHelper, error) {
	// remember to use a secure key for the pickle key in production
	pickleKey := []byte(pickleKeyString)

	// this is a path to the SQLite database you will use to store various data about your bot
	dbPath := "crypto.db"

	helper, err := cryptohelper.NewCryptoHelper(cli, pickleKey, dbPath)
	if err != nil {
		return nil, err
	}

	// initialize the database and other stuff
	err = helper.Init(context.Background())
	if err != nil {
		return nil, err
	}

	return helper, nil
}

func verifyWithRecoveryKey(machine *crypto.OlmMachine) (err error) {
	ctx := context.Background()

	keyId, keyData, err := machine.SSSS.GetDefaultKeyData(ctx)
	if err != nil {
		return
	}
	key, err := keyData.VerifyRecoveryKey(keyId, recoveryKey)
	if err != nil {
		return
	}
	err = machine.FetchCrossSigningKeysFromSSSS(ctx, key)
	if err != nil {
		return
	}
	err = machine.SignOwnDevice(ctx, machine.OwnIdentity())
	if err != nil {
		return
	}
	err = machine.SignOwnMasterKey(ctx)

	return
}

func main() {
	client, err := mautrix.NewClient(homeserver, userId, accessToken)
	if err != nil {
		panic(err)
	}

	if deviceId == "" || userId == "" || accessToken == "" {
		resp, err := client.Login(context.Background(), &mautrix.ReqLogin{
			Type: mautrix.AuthTypePassword,
			Identifier: mautrix.UserIdentifier{
				User: username,
				Type: mautrix.IdentifierTypeUser,
			},
			Password:         password,
			StoreCredentials: true,
		})
		if err != nil {
			panic(err)
		}

		log.Println(resp.DeviceID)
		log.Println(resp.AccessToken)
		log.Println(resp.UserID)

		return
	}
	client.DeviceID = deviceId

	syncer := mautrix.NewDefaultSyncer()
	client.Syncer = syncer

	cryptoHelper, err := setupCryptoHelper(client)
	if err != nil {
		panic(err)
	}
	client.Crypto = cryptoHelper

	readyChan := make(chan bool)
	var once sync.Once
	syncer.OnSync(func(ctx context.Context, resp *mautrix.RespSync, since string) bool {
		once.Do(func() {
			close(readyChan)
		})

		return true
	})

	go func() {
		if err := client.Sync(); err != nil {
			panic(err)
		}
	}()

	log.Println("Waiting for sync to receive first event from the encrypted room...")
	<-readyChan
	log.Println("Sync received")

	err = verifyWithRecoveryKey(cryptoHelper.Machine())
	if err != nil {
		panic(err)
	}

	content := event.MessageEventContent{
		MsgType: event.MsgText,
		Body:    "Hello world from Go!",
	}

	_, err = client.SendMessageEvent(context.Background(), roomID, event.EventMessage, content)
	if err != nil {
		panic(err)
	}
}

Conclusion

With this approach, your Matrix bot securely communicates within encrypted rooms. Remember to securely store credentials, use secure keys, and manage device verification properly in production. Happy coding!

View original on chrastecky.dev
4

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!

View original on chrastecky.dev
34

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.

View original on chrastecky.dev
7

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?

View original on chrastecky.dev
8

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:

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

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!

View original on chrastecky.dev
16

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

View original on chrastecky.dev
19

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.

Update 2025-09-02: Doctrine can now optionally use native lazy objects as of version 3.4.0.

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.

View original on chrastecky.dev
9

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.

View original on chrastecky.dev
5