Psalm rules
The nexus-psalm package ships a Psalm plugin with rules and return-type providers that enforce Nexus-specific correctness at static analysis time. Register it in psalm.xml:
<plugins>
<pluginClass class="Monadial\Nexus\Psalm\NexusPlugin" />
</plugins>
All seven rules run at Psalm level 1 (the strictest level). Psalm reports violations as custom issue types; suppress with @psalm-suppress IssueTypeName on the specific line or method docblock.
ReadonlyMessageRule
Hook class: Monadial\Nexus\Psalm\Hook\ReadonlyMessageRule
What it catches: Messages passed to ActorRef::tell(), ActorContext::scheduleOnce(), and ActorContext::scheduleRepeatedly() that are not declared readonly class.
Issue type: NonReadonlyMessage
Example error:
ERROR: NonReadonlyMessage: Message class OrderCreated passed to tell() is not readonly.
Mutable messages are unsafe in concurrent actor systems.
Why: Actors run on separate fibers or threads. A mutable message object shared between actors can be mutated by one actor while another is reading it, producing data races. readonly class enforces copy-on-write semantics and communicates immutability intent.
Fix:
class OrderCreated // missing readonly — Psalm will flag this
{
public string $orderId;
}
final readonly class OrderCreated
{
public function __construct(public string $orderId) {}
}
MutableActorStateRule
Hook class: Monadial\Nexus\Psalm\Hook\MutableActorStateRule
What it catches: Public non-readonly properties on classes that implement ActorHandler or StatefulActorHandler.
Issue type: MutableActorState
Example error:
ERROR: MutableActorState: Property CounterActor::$count is public and mutable.
Actor handler properties must be readonly to prevent shared-state bugs.
Why: Class-based actors are instantiated once and handle many messages sequentially. A public mutable property creates a shared state surface accessible from outside the actor. All per-actor state should be encapsulated via the handler's private/readonly properties or via StatefulActorHandler::initialState().
Fix:
class CounterActor implements ActorHandler
{
public int $count = 0; // public + mutable — flagged
}
use Monadial\Nexus\Core\Actor\StatefulActorHandler;
final class CounterActor implements StatefulActorHandler
{
public function initialState(): int
{
return 0;
}
// state flows through StatefulActorHandler::handle(), not properties
}
NonSerializableRemoteMessageRule
Hook class: Monadial\Nexus\Psalm\Hook\NonSerializableRemoteMessageRule
What it catches: Messages passed to WorkerActorRef::tell() (cross-worker tells) that do not have a #[MessageType] attribute.
Issue type: NonSerializableRemoteMessage
Example error:
ERROR: NonSerializableRemoteMessage: Message class GetOrder passed to WorkerActorRef::tell()
has no #[MessageType] attribute. Cross-worker messages require a stable type name.
Why: WorkerActorRef::tell() routes messages across Swoole thread boundaries. The serializer uses #[MessageType] as the stable wire-format key. Without it, the message cannot be deserialized on the receiving worker.
Fix:
use Monadial\Nexus\Serialization\MessageType;
#[MessageType('orders.GetOrder')]
final readonly class GetOrder
{
public function __construct(public readonly string $orderId) {}
}
See also: Attributes — #[MessageType]
BlockingCallInHandlerRule
Hook class: Monadial\Nexus\Psalm\Hook\BlockingCallInHandlerRule
What it catches: Calls to blocking PHP functions inside classes that implement ActorHandler or StatefulActorHandler.
Issue type: BlockingCallInHandler
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.
Example error:
ERROR: BlockingCallInHandler: Blocking call 'file_get_contents' inside ActorHandler.
Blocking calls stall the fiber scheduler and starve all other actors.
Why: Actor handlers run on PHP fibers. A blocking call (sleep, curl_exec, etc.) parks the OS thread rather than suspending the fiber, starving all other actors running on the same thread. Use async-capable alternatives: $ctx->scheduleOnce() for delays, Swoole coroutine HTTP clients for I/O.
Fix:
public function handle(ActorContext $ctx, object $msg): Behavior
{
$data = file_get_contents('https://api.example.com/data'); // blocks fiber
return Behavior::same();
}
// Use Swoole coroutine client inside SwooleRuntime, or send the result
// via a dedicated IO actor that uses non-blocking APIs
public function handle(ActorContext $ctx, object $msg): Behavior
{
// delegate to a separate I/O actor or use runtime::scheduleOnce
return Behavior::same();
}
MutableClosureCaptureRule
Hook class: Monadial\Nexus\Psalm\Hook\MutableClosureCaptureRule
What it catches: Closures passed to Props::fromFactory(), Props::fromStatefulFactory(), or Props::fromContainer() that capture variables by reference (use (&$var)).
Issue type: MutableClosureCapture
Example error:
ERROR: MutableClosureCapture: Variable $counter captured by reference in Props::fromFactory() closure.
Factory closures must not capture by reference — each worker gets its own instance.
Why: Props::fromFactory() closures are called once per actor spawn (and once per worker thread in a worker pool). By-reference captures create a shared mutable variable across spawns, breaking actor isolation.
Fix:
$counter = 0;
Props::fromFactory(function () use (&$counter) { // by-ref capture — flagged
$counter++;
return new CounterActor();
});
Props::fromFactory(static fn() => new CounterActor());
// State lives inside CounterActor, not in the closure
PropsReturnTypeProvider
Hook class: Monadial\Nexus\Psalm\Hook\PropsReturnTypeProvider
What it does: A return-type provider (not a rule) that infers the generic type parameter for Props::fromBehavior(), Props::fromFactory(), and related named constructors. Psalm cannot infer Props<T> from the closure's message type without help.
Example: Without this provider, Props::fromBehavior($behavior) returns Props<object>. With it, Psalm infers Props<MyMessage> from the behavior's handler signature.
No suppressible error. This provider adds type information rather than reporting issues. If Props<T> appears as Props<object> in your Psalm output, check that the plugin is registered.
CloneWithReturnTypeProvider
Hook class: Monadial\Nexus\Psalm\Hook\CloneWithReturnTypeProvider
What it does: A return-type provider for PHP 8.4's clone() expression with named arguments (e.g., clone($this, ['capacity' => $n])). Without this provider, Psalm infers the return type as mixed. With it, Psalm preserves the object's original type through the clone operation.
No suppressible error. This provider is purely additive. If you see mixed instead of the expected class type after a clone(...) call, verify the plugin is registered and Psalm is at least version 5.x.
See also
- Attributes — the
#[MessageType],#[ReplyType]attributes that several rules reference - Gotchas — factory closure capture rules
- Gotchas — #[MessageType] required for cross-worker
- Core concepts — actors —
ActorHandlerandStatefulActorHandlerinterfaces