Skip to content

feat(core): make the JWS and JWE builders immutable - #706

Merged
Spomky merged 1 commit into
4.3.xfrom
feature/immutable-builders
Aug 29, 2026
Merged

feat(core): make the JWS and JWE builders immutable#706
Spomky merged 1 commit into
4.3.xfrom
feature/immutable-builders

Conversation

@Spomky

@Spomky Spomky commented Aug 28, 2026

Copy link
Copy Markdown
Member

Target branch: 4.3.x
Resolves issue #683

  • It is a Bug fix
  • It is a New feature
  • It is related to dependencies

Includes:

  • Breaks BC
  • Deprecations

What this does

JWSBuilder and JWEBuilder advertised an immutable with*() API they did not honour: they
accumulated state on the receiver and validated during accumulation, which is what made create()
necessary and what produced #678, #679 and #680.

Purely functional accumulation. Every with*() / add*() method clones first and never touches
$this. A builder registered as a shared service can no longer be poisoned by a previous build.

Validation moved to build(). Everything that involves more than one call is now checked at build
time, so any call order works:

  • JWSBuilder reads the b64 payload encoding from the signatures instead of pinning it on the
    receiver, so withEncodedPayload() may come after addSignature().
  • JWEBuilder resolves the key and the content encryption algorithms from the complete header of each
    recipient, so addRecipient() may come before the shared header that carries its alg / enc
    (the Parameter "enc" is missing. symptom of JWEBuilder: fatal error when a shared header is set after a recipient has been added #678).
  • The disjoint header requirement of RFC 7516 is verified for the three headers at build time.

Exception types and messages are unchanged; only the point at which they are raised moved.

Typed internal state. The array{key: JWK, header: array, …} entries are replaced by the
@internal readonly SignatureSpec and RecipientSpec value objects, which also removes the dead
sender_key entry.

create() is deprecated with trigger_deprecation() and now returns a pristine clone instead of
resetting the receiver.

The static create() named constructor

The issue also plans a static create(AlgorithmManager $algorithms): static for 4.3.0. PHP cannot
declare a static and an instance method under the same name, and __callStatic() is not reached when a
public non-static method of that name exists — so shipping it now would turn every existing
$builder->create() into a fatal error. It is therefore left to 5.0.0, together with the removal of the
reset, exactly as the issue plans for that version. The deprecation message points at
new JWSBuilder($algorithmManager) / new JWEBuilder($algorithmManager), which is the replacement
available today.

BC

No break. The public signatures, the exception types and the exception messages are untouched, and the
$signatures / $recipients protected properties keep their name and visibility. Their element type
changes from an array to a value object; the two places that leaked them — the JWSBuiltFailureEvent
and JWEBuiltFailureEvent dispatched by the bundle builders — keep receiving the previous array shape.
JWEBuiltFailureEvent now omits key_encryption_algorithm for a recipient whose alg cannot be
resolved, which was impossible before as such a recipient was rejected by addRecipient().

Two behaviours change for code that was already failing: an invalid call order that used to throw at
addRecipient() / withSharedHeader() now throws the same exception at build(), and a non-string
alg / enc raises InvalidArgumentException instead of a TypeError.

Tests

  • JWSBuilderImmutabilityTest and JWEBuilderImmutabilityTest cover the pure accumulation, the call
    order independence and the create() deprecation.
  • The three EncrypterTest duplicated-header tests and one SignerTest encoded-payload test now call
    build(), since that is where the check happens.
  • The 112 ->create() calls of the suite are removed: they would emit the new deprecation, and their
    removal is the migration the deprecation asks for.

QA

PHPUnit (898), PHPStan (level max), ECS, Rector and Deptrac all pass. 46 PHPStan baseline entries for
JWEBuilder and JWSBuilder are gone, the internal state being typed now.

The builders mixed three responsibilities: a shared service holding an algorithm
manager, an accumulator of mutable state, and an immutable "with*()" API they did
not honour. "create()" was not a named constructor but a reset, which only existed
because the state leaked.

Every accumulation method now clones first and never touches the receiver, and
every check that spans several calls is performed by "build()", so that the order
of the calls no longer matters:

* JWSBuilder::addSignature() no longer pins the payload encoding on the receiver.
  The "b64" consistency checks are read from the signatures by build().
* JWEBuilder::addRecipient() no longer resolves the key and content encryption
  algorithms. They are read from the complete header of each recipient by build(),
  hence a recipient can be added before the shared headers carrying its "alg" and
  "enc" parameters.
* The disjoint header requirement of RFC 7516 is verified for the three headers by
  build(), whatever the order in which they were set.

The internal array entries are replaced by the @internal readonly SignatureSpec and
RecipientSpec value objects, which also removes the dead "sender_key" entry. The
bundle failure events keep the array shape they were given until now.

create() is deprecated and will be removed in 5.0.0, where it becomes the static
named constructor. The exception types and messages are unchanged.

Closes #683
@Spomky Spomky self-assigned this Aug 28, 2026
@Spomky Spomky added the feature label Aug 28, 2026
@Spomky Spomky added this to the 4.3.0 milestone Aug 28, 2026
@Spomky Spomky linked an issue Aug 28, 2026 that may be closed by this pull request
@Spomky
Spomky merged commit 7a01072 into 4.3.x Aug 29, 2026
17 checks passed
@Spomky
Spomky deleted the feature/immutable-builders branch August 29, 2026 16:16
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

Projects

None yet

Development

Successfully merging this pull request may close these issues.

Make the JWS/JWE builders truly immutable and deprecate create()

1 participant