feat(core): make the JWS and JWE builders immutable - #706
Merged
Conversation
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
This was referenced Aug 29, 2026
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
Target branch: 4.3.x
Resolves issue #683
Includes:
What this does
JWSBuilderandJWEBuilderadvertised an immutablewith*()API they did not honour: theyaccumulated 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 buildtime, so any call order works:
JWSBuilderreads theb64payload encoding from the signatures instead of pinning it on thereceiver, so
withEncodedPayload()may come afteraddSignature().JWEBuilderresolves the key and the content encryption algorithms from the complete header of eachrecipient, so
addRecipient()may come before the shared header that carries itsalg/enc(the
Parameter "enc" is missing.symptom of JWEBuilder: fatal error when a shared header is set after a recipient has been added #678).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@internalreadonlySignatureSpecandRecipientSpecvalue objects, which also removes the deadsender_keyentry.create()is deprecated withtrigger_deprecation()and now returns a pristine clone instead ofresetting the receiver.
The static
create()named constructorThe issue also plans a static
create(AlgorithmManager $algorithms): staticfor 4.3.0. PHP cannotdeclare a static and an instance method under the same name, and
__callStatic()is not reached when apublic 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 thereset, exactly as the issue plans for that version. The deprecation message points at
new JWSBuilder($algorithmManager)/new JWEBuilder($algorithmManager), which is the replacementavailable today.
BC
No break. The public signatures, the exception types and the exception messages are untouched, and the
$signatures/$recipientsprotected properties keep their name and visibility. Their element typechanges from an array to a value object; the two places that leaked them — the
JWSBuiltFailureEventand
JWEBuiltFailureEventdispatched by the bundle builders — keep receiving the previous array shape.JWEBuiltFailureEventnow omitskey_encryption_algorithmfor a recipient whosealgcannot beresolved, 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 atbuild(), and a non-stringalg/encraisesInvalidArgumentExceptioninstead of aTypeError.Tests
JWSBuilderImmutabilityTestandJWEBuilderImmutabilityTestcover the pure accumulation, the callorder independence and the
create()deprecation.EncrypterTestduplicated-header tests and oneSignerTestencoded-payload test now callbuild(), since that is where the check happens.->create()calls of the suite are removed: they would emit the new deprecation, and theirremoval is the migration the deprecation asks for.
QA
PHPUnit (898), PHPStan (level max), ECS, Rector and Deptrac all pass. 46 PHPStan baseline entries for
JWEBuilderandJWSBuilderare gone, the internal state being typed now.