diff --git a/src/ProjectorHandleProvider.php b/src/ProjectorHandleProvider.php
new file mode 100644
index 0000000..019b0c2
--- /dev/null
+++ b/src/ProjectorHandleProvider.php
@@ -0,0 +1,96 @@
+<?php
+
+declare(strict_types=1);
+
+namespace Patchlevel\EventSourcingPsalmPlugin;
+
+use Patchlevel\EventSourcing\Attribute\Handle;
+use Patchlevel\EventSourcing\EventBus\Message;
+use Patchlevel\EventSourcing\Projection\Projector;
+use Psalm\Plugin\EventHandler\AfterClassLikeVisitInterface;
+use Psalm\Plugin\EventHandler\Event\AfterClassLikeVisitEvent;
+use Psalm\Storage\MethodStorage;
+use Psalm\Type;
+use Psalm\Type\Atomic\TNamedObject;
+
+use function array_values;
+use function in_array;
+
+class ProjectorHandleProvider implements AfterClassLikeVisitInterface
+{
+    public static function afterClassLikeVisit(AfterClassLikeVisitEvent $event): void
+    {
+        $storage = $event->getStorage();
+
+        if (
+            !$storage->user_defined
+            || $storage->is_interface
+            || !in_array(Projector::class, $storage->direct_class_interfaces)
+        ) {
+            return;
+        }
+
+        foreach ($storage->methods as $method) {
+            $events = self::handledEvents($method);
+
+            if ($events === []) {
+                continue;
+            }
+
+            $param = $method->params[0] ?? null;
+
+            if (!$param) {
+                continue;
+            }
+
+            $param->type = new Type\Union([
+                new Type\Atomic\TGenericObject(Message::class, [
+                    new Type\Union($events),
+                ]),
+            ]);
+        }
+    }
+
+    /**
+     * @return list<TNamedObject>
+     */
+    private static function handledEvents(MethodStorage $method): array
+    {
+        $events = [];
+
+        foreach ($method->attributes as $attribute) {
+            if ($attribute->fq_class_name !== Handle::class) {
+                continue;
+            }
+
+            $arg = $attribute->args[0] ?? null;
+
+            if (!$arg) {
+                continue;
+            }
+
+            $type = $arg->type;
+
+            if (!$type instanceof Type\Union) {
+                continue;
+            }
+
+            $atomicType = self::firstAtomicType($type);
+
+            if (!$atomicType instanceof Type\Atomic\TLiteralClassString) {
+                continue;
+            }
+
+            $events[] = new TNamedObject($atomicType->value);
+        }
+
+        return $events;
+    }
+
+    private static function firstAtomicType(Type\Union $union): ?Type\Atomic
+    {
+        $types = array_values($union->getAtomicTypes());
+
+        return $types[0] ?? null;
+    }
+}