nexus-psalm
Psalm plugin for static analysis of Nexus actor code. Provides custom rules that enforce actor-model safety and type providers that improve generic type inference.
Composer: nexus-actors/psalm
Namespace: Monadial\Nexus\Psalm\
Setup
Register the plugin in your psalm.xml:
<plugins>
<pluginClass class="Monadial\Nexus\Psalm\Plugin" />
</plugins>
The plugin class implements Psalm\Plugin\PluginEntryPointInterface.
Recommended configuration
Nexus is developed and tested at Psalm Level 1 (the strictest level). This level is recommended for projects using Nexus to get the full benefit of generic type checking on actor message protocols.
<?xml version="1.0"?>
<psalm
errorLevel="1"
resolveFromConfigFile="true"
xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance"
xmlns="https://getpsalm.org/schema/config"
xsi:schemaLocation="https://getpsalm.org/schema/config vendor/vimeo/psalm/config.xsd"
>
<projectFiles>
<directory name="src" />
</projectFiles>
<plugins>
<pluginClass class="Monadial\Nexus\Psalm\Plugin" />
</plugins>
</psalm>
Actor-model safety rules
The plugin enforces five rules that catch common actor-model violations at analysis time, before they become runtime bugs.
NonReadonlyMessage
Actor messages must be immutable. This rule flags any non-readonly class
passed to ActorRef::tell(), ActorContext::scheduleOnce(), or
ActorContext::scheduleRepeatedly().
// Good — readonly class is immutable
final readonly class Greet {
public function __construct(public string $name) {}
}
$ref->tell(new Greet('world')); // OK
// Bad — mutable class can be changed after send
final class MutableGreet {
public function __construct(public string $name) {}
}
$ref->tell(new MutableGreet('world')); // ERROR: NonReadonlyMessage
Why: When an actor sends a message, the sender should not be able to mutate it afterward. Mutable messages break actor isolation and cause data races in concurrent systems.
Suppress with @psalm-suppress NonReadonlyMessage if needed.
MutableActorState
Actor handlers should not expose mutable state through public properties.
This rule flags any public non-readonly non-static property on classes
implementing ActorHandler or StatefulActorHandler.
// Good — readonly class, no mutable state
final readonly class MyHandler implements ActorHandler {
public function __construct(private string $name) {}
// ...
}
// Bad — public mutable property
final class BadHandler implements ActorHandler {
public int $count = 0; // ERROR: MutableActorState
// ...
}
Why: Public mutable state on actor handlers can be modified from outside
the actor, breaking encapsulation. Use readonly classes or reduce property
visibility.
Suppress with @psalm-suppress MutableActorState if needed.
NonSerializableClusterMessage
Messages sent through RemoteActorRef::tell() must be registered in the
TypeRegistry for cross-worker serialization. This rule flags message classes
that lack a #[MessageType] attribute.
use Monadial\Nexus\Serialization\MessageType;
// Good — registered for serialization
#[MessageType('order.created')]
final readonly class OrderCreated {
public function __construct(public string $orderId) {}
}
// Bad — will fail at runtime when sent across workers
final readonly class UnregisteredEvent {
public function __construct(public string $data) {}
}
$remoteRef->tell(new UnregisteredEvent('x')); // ERROR: NonSerializableClusterMessage
Why: In a clustered deployment, messages crossing worker boundaries are
serialized. Without a stable type identifier from #[MessageType], the
serializer cannot reconstruct the message on the receiving worker.
This rule only applies to RemoteActorRef::tell() — local actor references
are not checked since messages stay in-process.
Suppress with @psalm-suppress NonSerializableClusterMessage if needed.
BlockingCallInHandler
Actor handlers must not call blocking functions. This rule flags calls to
sleep(), usleep(), file_get_contents(), curl_exec(), and other
blocking I/O functions inside classes implementing ActorHandler or
StatefulActorHandler.
final readonly class SlowHandler implements ActorHandler {
public function handle(ActorContext $ctx, object $message): Behavior
{
sleep(1); // ERROR: BlockingCallInHandler
file_get_contents('https://example.com'); // ERROR: BlockingCallInHandler
return Behavior::same();
}
}
Detected functions: sleep, usleep, time_nanosleep,
time_sleep_until, file_get_contents, file_put_contents, fread,
fwrite, fgets, fopen, curl_exec, proc_open, shell_exec, exec,
system, passthru, popen.
Why: Blocking calls inside actor handlers starve the runtime. A sleeping
actor prevents other actors from processing messages. Use
ActorContext::scheduleOnce() for delays and async I/O for external calls.
Non-actor classes are not checked — blocking is only flagged inside actor handler implementations.
Suppress with @psalm-suppress BlockingCallInHandler if needed.
MutableClosureCapture
Closures passed to Props::fromFactory() or Props::fromStatefulFactory()
must not capture variables by reference. This rule flags use (&$var)
captures in factory closures.
// Good — value capture
$name = 'worker';
Props::fromFactory(static function () use ($name): ActorHandler {
return new MyHandler($name);
});
// Good — arrow functions capture by value implicitly
Props::fromFactory(static fn () => new MyHandler($name));
// Bad — by-reference capture creates shared state
$counter = 0;
Props::fromFactory(static function () use (&$counter): ActorHandler { // ERROR: MutableClosureCapture
return new MyHandler($counter++);
});
Why: Actor factory closures are invoked once per spawn. A by-reference capture means every actor instance shares the same variable, creating hidden mutable shared state across actors.
Suppress with @psalm-suppress MutableClosureCapture if needed.
Type providers
PropsReturnTypeProvider
Improves Psalm's type inference for Props factory methods. Without this
provider, template parameters are often erased through closure boundaries,
causing Psalm to infer Props<object> instead of the specific message type.
Supported methods:
Props::fromContainer($container, MyHandler::class)— extractsTfromclass-string<ActorHandler<T>>and returnsProps<T>.Props::fromFactory(fn() => new MyHandler())— inspects the closure's return type, looks up the handler's template parameters, and returnsProps<T>.Props::fromStatefulFactory(fn() => new MyStatefulHandler())— same as above forStatefulActorHandler<T, S>, returnsProps<T>.
This means downstream code gets proper type inference:
// Without plugin: Props<object>
// With plugin: Props<MyCommand>
$props = Props::fromContainer($container, MyHandler::class);
// spawn() returns ActorRef<MyCommand>
$ref = $system->spawn($props, 'my-actor');
// tell() only accepts MyCommand
$ref->tell(new MyCommand('hello')); // OK
$ref->tell(new WrongType()); // Psalm error: type mismatch
Generic type safety
Nexus uses @template T of object generics throughout its public API. Key
generic types include:
ActorRef<T>— Ensurestell()only accepts messages of typeT.ActorContext<T>— Scopesself(),scheduleOnce(), andscheduleRepeatedly()to the actor's message type.Behavior<T>— Links message handler closures to the actor's protocol type.BehaviorWithState<T, S>— Adds a state type parameter for stateful actors.Props<T>— Carries the message type through toActorSystem::spawn().
At Psalm Level 1, these generics provide compile-time verification that:
- Actors only receive messages matching their declared protocol.
- Behavior handlers return correctly typed
Behavior<T>values. Propscreated viafromBehavior(),fromFactory(), orfromContainer()preserve the message type.ActorContext::spawn()returns anActorRef<C>matching the child'sProps<C>.
Running vendor/bin/psalm with the Nexus plugin catches type mismatches in
actor message handling before runtime.
Suppressing issues
All custom issues can be suppressed per-line with @psalm-suppress:
/** @psalm-suppress NonReadonlyMessage legitimate use of mutable message */
$ref->tell(new LegacyMessage());
Or globally in psalm.xml:
<issueHandlers>
<PluginIssue name="NonReadonlyMessage">
<errorLevel type="suppress">
<directory name="src/Legacy" />
</errorLevel>
</PluginIssue>
</issueHandlers>