This file is the repo operating manual for AI agents. It describes how to work inside this repository without breaking its design. Specialist skills in .agents/skills/ provide deeper playbooks for specific layers.
If a change crosses layers, load multiple skills and use the playbooks in this file to keep boundaries clean.
Before changing anything:
- Identify the layer you are touching.
- Read the base abstraction for that layer before copying a concrete class.
- Read one representative concrete implementation from the same family.
- Trace how that layer connects to adjacent layers.
- Make the change in the narrowest layer that can own it.
- Update companion surfaces that define the same contract.
In this repo, companion surfaces usually matter as much as the line you edit.
- CLI-only runtime. DiscordPHP is a long-running process built on ReactPHP. Do not design around web requests, controllers, middleware stacks, or per-request state.
- Async first. Production I/O is Promise-based. Blocking helpers belong in tests only.
- Parts are canonical domain objects. They model Discord resources and expose typed magic properties.
- Repositories are persistence and cache boundaries. They are not generic service classes.
- Gateway handlers keep caches coherent. They do more than relay notifications.
- Builders own outbound payload rules. If a payload has meaningful shape or validation, it usually deserves a builder.
- Docblocks are runtime-adjacent documentation. They are not optional decoration.
- Traits are preferred over deep inheritance or broad interface hierarchies for shared behavior.
- Type maps are central dispatch points. If a Discord payload is polymorphic, there is usually one place that decides the concrete subtype.
- Use current library idioms. Prefer
Factory::part()/Factory::repository(), prefer$part->save($reason)over repositorysave($part)in user-facing paths, and prefer builder->create($repository)helpers where they exist.
Each skill lives in .agents/skills/<name>/SKILL.md and is loaded automatically when relevant. When a task crosses layers, load multiple skills.
| If task touches... | Skill to load |
|---|---|
Discord.php, startup, intents, cache, loop, gateway connection |
runtime-bootstrap-keeper |
Parts/*, domain modeling, mutators, typed nested data |
part-model-maintainer |
Repository/*, endpoint vars, cache, CRUD, fetch/save/delete |
repository-cache-keeper |
WebSockets/Handlers.php, WebSockets/Event.php, WebSockets/Events/* |
gateway-cache-sync-keeper |
Builders/*, Builders/Components/*, outbound payload rules |
builder-payload-smith |
subtype maps like Channel::TYPES or Interaction::TYPES |
type-map-keeper |
| interactions, slash commands, resolved data, autocomplete, modals | interaction-flow-keeper |
DiscordCommandClient or prefix-command behavior |
legacy-command-client-keeper |
| tests, guides, docblocks, generated reference expectations | async-test-and-doc-sync |
Voice/*, audio streaming, encryption, voice gateway protocol |
voice-subsystem-keeper |
Helpers/*, Exceptions/*, cache wrappers, Endpoint::bind(), Collection |
helpers-and-infra-keeper |
| Layer | Owns | Primary files | What to preserve |
|---|---|---|---|
| Runtime | process lifecycle, options, loop, gateway, HTTP, root repos | src/Discord/Discord.php |
__construct() wires dependencies and connects; run() only starts loop |
| Factory | part/repository instantiation | src/Discord/Factory/Factory.php |
callers should not construct repo families ad hoc |
| Parts | domain objects, mutators, typed nested data, high-level operations | src/Discord/Parts/Part.php, src/Discord/Parts/PartTrait.php, src/Discord/Parts/**/* |
$fillable, mutators, PHPDoc, save() semantics, created lifecycle |
| Repositories | typed collections, cache, REST endpoints, CRUD | src/Discord/Repository/AbstractRepository.php, src/Discord/Repository/**/* |
$class, $endpoints, $vars, cache writes, Promise-based API |
| Builders | outbound payload construction and validation | src/Discord/Builders/**/* |
fluent setters, validation, jsonSerialize(), fromPart() symmetry |
| Gateway events | payload hydration, cache mutation, emitted return shapes | src/Discord/WebSockets/Handlers.php, src/Discord/WebSockets/Event.php, src/Discord/WebSockets/Events/* |
typed part creation, related cache updates, event contract shape |
| Helpers | cross-cutting utilities: cache wrappers, BigInt math, multipart uploads, property mutator trait, domain exceptions | src/Discord/Helpers/*, src/Discord/Exceptions/* |
no domain logic here; utilities only |
| Voice | internal voice protocol types and encryption; runtime integration in Discord.php; audio client in external discord-php-helpers/voice package |
src/Discord/Voice/* |
Old* files are legacy — do not extend them; keep protocol and crypto layers separate |
| Optional command layer | message-prefix command UX | src/Discord/DiscordCommandClient.php, src/Discord/CommandClient/Command.php |
keep it layered on top of core client, not inside it |
| Tests and docs | behavioral contract | tests/*, guide/*, README.md, docs/* |
async testing patterns, public guidance, docblock reference surface |
Three external packages are tightly coupled to core runtime behavior. Treat them as first-class parts of the architecture:
| Package | Why it matters | Local touchpoints |
|---|---|---|
discord-php/http |
HTTP client and Endpoint URL template binding used in every repository; handles rate-limiting via Bucket |
$endpoints arrays in all repositories; use Discord\Http\Endpoint imports |
discord-php-helpers/collection |
Collection class is the base for every AbstractRepository; discriminator-keyed typed collections |
AbstractRepository extends Collection; $discrim property |
discord-php-helpers/voice |
Manager and VoiceClient implement the actual audio client; src/Discord/Voice/* contains only internal protocol types and encryption |
Discord::joinVoiceChannel(), voice event handlers in Discord.php |
When editing endpoint bindings, REST routes, cache storage, or voice audio, you are necessarily touching these packages' contracts.
Discord owns bootstrapping. Gateway dispatch goes through Handlers into a dedicated event class. Event classes translate payloads into typed parts and update repositories. Repositories provide cache and REST persistence. Parts expose that state through magic properties and typed helpers. Builders assemble outbound payloads for operations that would otherwise become fragile arrays.
- If you put transport logic in parts, parts stop being stable domain objects.
- If you put domain validation in repositories, repository APIs stop being predictable.
- If you skip builders, payload rules spread across unrelated methods.
- If gateway handlers do not update caches correctly, every downstream relation becomes stale.
Expect these elements on real resource models:
- large class-level
@propertyand@property-readdocblocks protected $fillable = [...]- optional
protected $repositories = [...] - constants mirroring Discord enums or flags
getXAttribute()andsetXAttribute()mutators- overrides for
getCreatableAttributes(),getUpdatableAttributes(),getRepository(),save(),fetch(), orgetRepositoryAttributes()when the part is persistable
Semantic rules:
- raw attribute names stay snake_case to match Discord payloads
- convenience relations (
guild,channel,owner,member) are usually computed, not directly stored - nested typed data should become a
Part, typed collection, orCarbonvalue through helper methods - permission checks for high-level mutations belong on the part before repository delegation
createdtells you whether the object already exists remotely
Expect these elements:
extends AbstractRepositoryprotected $class = SomePart::classprotected $endpoints = [...]- optional constructor normalization for route vars
- occasional domain-specific convenience methods
Semantic rules:
- repositories are typed collections plus REST/cache wrappers
create()builds a local part;save()persists it$varscarries parent route context likeguild_idorchannel_id- cache writes must stay aligned with REST writes and gateway updates
- special methods should still return typed parts or repositories, not loose payloads
Expect these elements:
extends Builderimplements JsonSerializable- fluent
setX()/getX()methods - eager validation in setters or adders
new()factory method on most builderscreate($repository)helper on newer builders
Semantic rules:
- builders are not parts and should not own persistence or cache logic
- validation belongs here when it describes outgoing payload shape
jsonSerialize()should omit unset optionals when Discord distinguishes missing from explicit nullfromPart()should make edit flows symmetrical
Expect these elements:
- one class per event type under
src/Discord/WebSockets/Events - matching constant in
src/Discord/WebSockets/Event.php - matching registration in
src/Discord/WebSockets/Handlers.php handle($data)method returning typed semantic values
Semantic rules:
- event handlers should hydrate the right subtype on first read
- event handlers are responsible for repository/cache coherence
- update events often return both new and old state
- delete events often return cached removed state, not only the raw payload id
- related user/member caches usually need updating too
If a part can be saved:
- the part decides whether the action is allowed and which repository owns it
- the repository decides how to call Discord and update cache
Smell: a part method manually building endpoints and PATCH payloads when a repository already exists for that family.
If userland needs to construct a non-trivial outbound payload:
- add or extend a builder
- keep raw-array construction as an implementation detail only when a builder would be needless overhead
Smell: multiple methods assembling the same nested array by hand.
If Discord can tell us something through gateway dispatch:
- prefer updating cache from the event instead of forcing later REST refetches
- keep parent/child repository relationships coherent at the same time
Smell: event handler creates a part but does not update the repository that should own it.
The codebase prefers traits for shared capabilities across sibling models.
Examples:
PartTraitfor universal part mechanicsChannelTraitfor channel/thread behaviorGuildTraitfor guild asset and feature helpersDynamicPropertyMutatorTraitfor builder/property mutators (src/Discord/Helpers/DynamicPropertyMutatorTrait.php)AbstractRepositoryTraitfor repository collection mechanicsComponentsTraitfor shared component helpers across builders (src/Discord/Builders/ComponentsTrait.php)VoiceGroupCryptoTraitfor voice channel encryption/decryption
Smell: new abstract intermediate base class that only exists to share a few methods between peers that already have a common root.
When a Discord discriminator chooses a subtype:
- extend the relevant
TYPESmap - update all materialization sites that depend on that family
Smell: one event handler special-cases a new subtype but the central type map still does not know it.
If you touch one of these, inspect the companions too:
| Touching | Also inspect |
|---|---|
src/Discord/Parts/Part.php or PartTrait.php |
representative parts, PartInterface, generated docblocks |
a concrete Part |
owning repository, related trait, event handlers, tests, docs |
| a nested repository relationship | parent part $repositories, getRepositoryAttributes(), repository constructor vars |
src/Discord/Repository/AbstractRepository* |
representative repos, cache wrapper behavior, part save/fetch overrides |
| a gateway event class | Event.php, Handlers.php, related part/repository, tests |
| a builder | matching part, callers, tests, docs/examples |
Channel::TYPES, Interaction::TYPES, component/ embed maps |
event handlers, typed collection helpers, builder mirrors |
DiscordCommandClient |
CommandClient/Command.php, examples/docs |
| public magic properties | class PHPDoc, guide docs if user-facing behavior changed |
Helpers/CacheWrapper.php or cache config |
AbstractRepository cache behavior, Discord.php options['cache'] wiring |
Voice/* or voice event in Discord.php |
VoiceGroupCrypto, VoicePacket, external voice package entry points, voice opcodes |
- Update
$fillable. - Add or adjust mutators for typed nested data, computed properties, or normalization.
- Update class docblocks.
- Update
$repositoriesif a new child repository is exposed. - Update
getRepositoryAttributes()if child route vars changed. - Update
getCreatableAttributes()/getUpdatableAttributes()if persistence shape changed. - Update
getRepository()orsave()if ownership or permission rules changed. - Check gateway events and repositories that hydrate or cache the part.
- Add or update tests.
- Confirm
$class,$discrim, and$endpointsstill describe the family correctly. - Confirm parent route vars are complete and in the correct shape.
- Keep REST writes and cache writes in sync.
- Return typed values.
- Check owning parts for
getRepository()andgetRepositoryAttributes()assumptions. - Update tests and docs if public behavior changed.
- Put payload validation in setters/adders.
- Keep fluent chaining style.
- Keep
jsonSerialize()aligned with Discord docs and existing payload semantics. - Keep
fromPart()edit symmetry in mind. - Update tests for validation limits and payload shape.
- Update docs/examples if the preferred public construction path changed.
- Add or update the event constant in
Event.phpif needed. - Add or update the handler registration in
Handlers.php. - Hydrate the correct subtype.
- Update every affected repository and relation cache.
- Preserve event return shape expected by userland listeners.
- Cache related users/members if the payload supplies them.
- Re-check any intent-gated or partial-data behavior.
- Keep application-command and prefix-command layers separate.
- Keep interaction typing and resolved-data hydration intact.
- Keep builders as the outbound authoring path for commands, components, and modals.
- Avoid slow interaction-time work that can delay a response.
- Update both inbound event handling and outbound builder/docs surfaces if public behavior shifts.
If you see one of these, slow down:
- a new raw nested array where a typed part already exists
- a repository method returning raw decoded payload instead of a part
- a part saving itself with hand-built endpoints even though an owning repository exists
- a new subtype without a
TYPESmap entry - a magic property added in code but not in docblocks
- a gateway handler that updates one cache but leaves related repositories stale
- a synchronous wait or loop-stop trick outside tests
- a new abstraction layer that duplicates what parts, repositories, events, or builders already do
- web-framework terminology creeping into core runtime code
- extending
OldVoiceClientor anyOld*class insrc/Discord/Voice/unless deliberately fixing legacy behavior - bypassing
Endpoint::bind()with hand-assembled raw URL strings
When you need an example worth imitating, start here:
- Runtime orchestration:
src/Discord/Discord.php - Base part mechanics:
src/Discord/Parts/Part.php,src/Discord/Parts/PartTrait.php - Rich part model:
src/Discord/Parts/Guild/Guild.php - Channel/resource semantics:
src/Discord/Parts/Channel/Channel.php - Message semantics and repository binding:
src/Discord/Parts/Channel/Message.php - Repository baseline:
src/Discord/Repository/AbstractRepository.php,src/Discord/Repository/AbstractRepositoryTrait.php - Simple repo specialization:
src/Discord/Repository/Channel/MessageRepository.php - Rich repo specialization:
src/Discord/Repository/GuildRepository.php - Outbound builder style:
src/Discord/Builders/MessageBuilder.php - Interaction typing:
src/Discord/Parts/Interactions/Interaction.php - Gateway cache mutation:
src/Discord/WebSockets/Events/MessageCreate.php,src/Discord/WebSockets/Events/GuildCreate.php - Optional command layer:
src/Discord/DiscordCommandClient.php,src/Discord/CommandClient/Command.php - Cache infrastructure:
src/Discord/Helpers/CacheWrapper.php,src/Discord/Helpers/CacheConfig.php - Voice protocol and encryption:
src/Discord/Voice/VoiceGroupCrypto.php,src/Discord/Voice/VoicePacket.php
- Prefer plain
PHPUnit\Framework\TestCasewhen logic is isolated. - Use
DiscordTestCaseonly when real Discord integration matters. - Use
wait()fromtests/functions.phpto bridge promises into test assertions. - Keep semantic tests focused on behavior, not on incidental implementation details.
- Public magic properties, repositories, and helpers should be reflected in PHPDoc.
- Long-form guides live in
guide/. - Gatsby docs live in
docs/. - Keep docs in sync when preferred usage or public contracts change.
| Purpose | Command |
|---|---|
| main PHPUnit suite | composer unit |
| static analysis | composer run-script mago-lint |
| formatter contributors run | composer run-script cs |
| non-mutating Pint check | ./vendor/bin/pint --test --config ./pint.json ./src |
| Pint formatter (auto-fix) | composer pint |
| test coverage report | composer coverage |
| docs build | cd docs && yarn install && yarn build |
Integration tests expect .env values for DISCORD_TOKEN, TEST_CHANNEL, and TEST_CHANNEL_NAME.
When unsure where code belongs, choose the layer that already owns the same kind of knowledge elsewhere in the repo. Matching the existing ownership model matters more than shaving a few lines off one class.