dominik

joined 5 months ago
MODERATOR OF
 

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?

[–] [email protected] 2 points 1 day ago

I like it as well, recently had one use case where it would have been the best solution, sadly php-cs-fixer chokes on that so I had to do it with property hooks.

 

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.

7
submitted 1 week ago* (last edited 1 week ago) by [email protected] to c/[email protected]
 

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?

 

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}"));
4
submitted 1 week ago* (last edited 1 week ago) by [email protected] to c/[email protected]
 

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!

[–] [email protected] 2 points 4 weeks ago

Same, but given how seriously they take BC breaks, I don't really see it happening. Well, at least we have mature tooling to avoid having horrible code in production code-bases.

[–] [email protected] 3 points 4 weeks ago (2 children)

Well, that's historical, if PHP was being designed today, I think a lot of the things would look very different. As everything since version 7.x, this is a step in the right direction of making the language modern and safer to use.

Like, this is still PHP, both of these are equally valid:

<?php

function hello(string $name): string {
  return "Hello, {$name}!";
}

function hello($name) {
  return "Hello, $name!";
}

So anything that makes it possible to write a good, clean code is a great addition, IMO.

10
submitted 4 weeks ago* (last edited 4 weeks ago) by [email protected] to c/[email protected]
 

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!

4
submitted 2 months ago* (last edited 1 week ago) by [email protected] to c/[email protected]
 

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:

 Screenshot of a Matrix room showing a message from “Test bot” saying “Hello world from Go!”, marked as not encrypted.

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:

 Screenshot of a Matrix room showing an encrypted message with a warning that the sending device hasn’t been verified.

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:

 Screenshot of the same message in a Matrix room, now encrypted and verified with no warning displayed.

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!

7
submitted 3 months ago* (last edited 3 months ago) by [email protected] to c/[email protected]
 

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.

[–] [email protected] 1 points 4 months ago

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

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

For example, the tag v2.7.082.

9
submitted 4 months ago* (last edited 4 months ago) by [email protected] to c/[email protected]
 

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?

[–] [email protected] 3 points 4 months ago

By the way, as mentioned in the post, if anyone can recommend a good algorithm to calculate Easter, let me know!

16
submitted 4 months ago* (last edited 3 months ago) by [email protected] to c/[email protected]
 

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

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

A calendar model screenshot for year 2025, showing that the Jan 1st is a Wednesday

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

A screenshot of a 3D model showing that Jan 1st 2056 is a Saturday

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!

[–] [email protected] 2 points 4 months ago* (last edited 4 months ago)

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

19
submitted 4 months ago* (last edited 1 month ago) by [email protected] to c/[email protected]
 

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

9
Lazy objects in PHP 8.4 (chrastecky.dev)
submitted 4 months ago* (last edited 2 months ago) by [email protected] to c/[email protected]
 

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

Without further ado, let's dive right in!

Lazy deserializer

For this project, I created a simple DTO:

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

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

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

This setup lets us write code like the following:

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

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

var_dump($object);

Implementing the deserializer

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

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

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

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

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

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

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

Here's an implementation of the private method:

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

Now, let’s implement assignValue:

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

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

        return $value;
    }

Here’s what happens in assignValue:

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

Here are some more objects that the deserializer now supports:

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

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

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

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

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

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

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

    return count($attributes) > 0;
}

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

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

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

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

var_dump($object);

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

$object->name;

var_dump($object);

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

$object->variant->color;

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

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

I/O bound entities

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

On my PC, this prints:

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

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

Partially initializing ghost objects

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

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

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

    return $entity;
}

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

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

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

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

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

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

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

This prints timings similar to:

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

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

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

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

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

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

Private key generating example

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

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

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

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

        $reflection = new ReflectionObject($keyPair);
        $reflection->getProperty('privateKey')->setValue($keyPair, $privateKeyPem);
        $reflection->getProperty('publicKey')->setValue($keyPair, $publicKeyPem);
    });
    assert($keyPair instanceof KeyPair);

    return $keyPair;
}

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

view more: next ›