dominik

joined 9 months ago
MODERATOR OF
[–] dominik@chrastecky.dev 1 points 1 month ago

I'd argue there's no reason ever to actually use it in production. This is such a horrible hack that it has no place outside dicking around for fun.

[–] dominik@chrastecky.dev 1 points 1 month ago

Updated the article, thanks! Wouldn't really say that's a stupid addon, though, the expected way to use readonly properties from other languages is assign the value directly (impossible in PHP) or inside the constructor (or other mechanisms that are only available during object creation, like C# initializers etc.).

 

Changing a Readonly Property

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

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

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

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

final readonly class ReadonlyClass
{
    public string $someProp;

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

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

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

This produces the predictable result:

string(13) "changed once!"

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

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

Changing It Multiple Times

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

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

Let’s start with this class:

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

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

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

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

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

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

And now comes the fun part:

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

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

This prints:

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

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

Changing an Enum Value

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

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

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

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

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

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

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

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

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

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

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

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

Breaking Types

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

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

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

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

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

Output:

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

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

Dynamic Properties Everywhere

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

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

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

Crashing PHP

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

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

var_dump($exception->getTraceAsString());

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

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

 

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

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

OPcache Is Now a Mandatory Part of PHP

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

Final Property Promotion

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

Attributes on Constants

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

Asymmetric Visibility for Static Properties

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

Easier Access to Error and Exception Handlers

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

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

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

New array_first() and array_last() Functions

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

A Saner Directory Class

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

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

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

#[Override] Can Now Apply to Properties

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

#[Deprecated] Can Be Used on Traits

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

Deprecations

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

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

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

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

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

Default values

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

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

Or this:

final class SomeClass
{
    private Closure $someCallable;

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

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

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

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

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

Attributes

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

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

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

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

First-Class Callables

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

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

<?php

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

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

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

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

Conclusion

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

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

[–] dominik@chrastecky.dev 1 points 4 months ago

Oh, right, that didn't occur to me. Well, guess I got used to it eventually. But yeah, the ... already does way too many things in PHP.

[–] dominik@chrastecky.dev 1 points 4 months ago (2 children)

I would've preferred ?, but ... is fine, IMO.

1
submitted 4 months ago* (last edited 4 months ago) by dominik@chrastecky.dev to c/programming@chrastecky.dev
 

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

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

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

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

echo $temp;

Or using indentation to improve readability:

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

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

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

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

How It Works

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

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

Operator Precedence

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

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

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

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

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

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

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

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

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

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

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

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

Higher-Order Functions

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

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

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

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

Caveats

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

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

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

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

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

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

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

Conclusion

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

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

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

 

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?

[–] dominik@chrastecky.dev 1 points 5 months 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.

 

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}"));
 

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!

[–] dominik@chrastecky.dev 2 points 6 months 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.

[–] dominik@chrastecky.dev 3 points 6 months 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.

 

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!

 

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!

[–] dominik@chrastecky.dev 1 points 8 months ago

I'll check it out, thanks!

[–] dominik@chrastecky.dev 2 points 8 months ago (4 children)

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

[–] dominik@chrastecky.dev 2 points 8 months ago (1 children)

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

[–] dominik@chrastecky.dev 1 points 8 months ago

Taky veselé Velikonoce! :)

[–] dominik@chrastecky.dev 3 points 8 months ago* (last edited 8 months ago) (3 children)

One of my favourite xkcd images! And yeah, creating another competing standard would suck, what's needed is more of a slow evolution towards a sane standard.

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

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

 

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

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

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

The Good

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

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

The Bad

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

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

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

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

The Ugly

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

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

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

Conclusion

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

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

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

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

view more: next ›