5.28.0 (2026-10-15)
assertAddOnClassExtensionsApply()asserts that every class extension an add-on registers actually applies. One that does not is silent - XenForo's own class runs, the add-on's code in the extension never executes, and a suite with no test on the extended class has nothing to go red, so it passes identically with the extension present and absent. The list comes from the database, so it works on an install with no development output; a more derived resolution counts as applied, since another add-on extending the same class resolves to its own; and an add-on registering no active extension is refused rather than passing over an empty list. Each state that cannot be asserted is refused with its own cause: an id matching no installed add-on, an add-on disabled, an add-on part way through an install, and an add-on registering nothing. XenForo's extension cache filters on the add-on's own state as well as each row's, so a disabled or mid-upgrade add-on keeps active rows that nothing resolves - reading the rows alone would report every one of them as a failure on an add-on that is merely switched off- docs:
assertAddOnClassExtensionsApply()readsxf_class_extension, so it does not see an artifact in_output/class_extensions/that was never imported - the drift a commit without an import leaves. A test reading that directory catches what this one cannot, so the two are complementary and replacing such a test with this one trades a check away
5.27.0 (2026-10-15)
describeReply()is protected rather than private, so a test can use it to see what a reply was. It renders each error phrase to its text, whichvar_export()andprint_r()do not: an error reply's errors areXF\Phraseobjects, and a phrase reaches the language, the database adapter and the adapter's configuration - so dumping one prints the database password and user in plain text, into the test output and from there into a build log. One phrase is enough- docs: the reply assertions carry that hazard and the safe routes -
describeReply(),replyErrors()and rendering - and note that the assertions compare text, so their own failures are safe to paste
5.26.0 (2026-10-14)
recordsLog($key)records what an add-on logs through a PSR-3 logger of its own, instead of writing it.fakesLogger()covers XenForo's moderator log; this is for an add-on that puts aPsr\Log\LoggerInterfacein the container under a key of its own, which nothing here could see into.assertLogged(),assertNotLogged()andassertNothingLogged()assert against it, with an optional level and either a string the message must contain or a closure given the whole entry;getLogRecords()andgetLogRecorder()read it. An entry islevel,messageandcontext, with the message as the code passed it and its{placeholder}braces intactcreateTempDirectory()creates an empty directory for a test to write into and removes it when the test finishes, so a test needs notearDown()of its own. The path is absolute and inside the system temporary directory, and the removal refuses any path that does not resolve inside there - so it cannot reach the forum. Reach for it rather than pairingFile::getTempDir()withFile::deleteDirectory(), which resolves a relative or empty path against the XenForo rootmakeEntity(),createEntity()andcreateUserAccount()refuse a value for a read-only or auto-increment column themselves, naming thesetTrusted()call that sets it. Such a call already failed, with XenForo's own message namingforceSet- an option this framework does not surface and not the one to reach for- docs:
UPGRADING.md's 5.0.0 entry says that listener filtering can take coverage away without anything failing. A suite with an optional integration with another add-on was testing the present branch by default before that release and the absent branch after it, so a workaround that forced the fallback becomes dead code that still passes and the present branch loses its only coverage - a green suite after upgrading is consistent with having stopped testing half of it. Adding the other add-on to$addonsToLoadis named as often unsafe, since booting one that vendors its own PHPUnit puts a second copy on XenForo's class loader- docs:
setOption()says that an option whose value is an array is replaced whole rather than merged, so naming two sub-options leaves the rest absent and the code under test fails on an undefined array key.registrationDefaultsholds fifteen on a stock install- docs:
fakesHttp()recommends installing an empty queue insetUp(), so a request no test expected is refused withMock queue is emptyrather than reaching the real endpoint with whatever credentials the forum holdsassertNothingLoggedContains($needle)asserts that no logged message contains the given text - for keeping a credential, a token or a user's data out of the log, where the per-message assertions need a message the test cannot predict. Errors and exceptions are both read, since an error is recorded as an\ErrorExceptionentry. Pair it with an assertion that something was logged: a run that logged nothing contains the text either. An empty needle is refusedgetLoggedMessages()returns the message of everything logged, in order and with each caller's prefix, for a test that asserts on the set rather than on one entrybuildVisitor(), and soactingAsMember()and the rest, accepts an array for a column XenForo stores as a string built from one -secondary_group_ids, which isLIST_COMMA. These values go in as stored column data, so the array used to reach the entity unencoded and the first read of the column was aTypeErrorinside XenForo's value formatter, naming neither the column nor the helper. The stored string is still accepted and is unchanged- docs:
buildVisitor()says its values go in as stored column data rather than through the entity's setters, which is what differs fromcreateUserAccount(), and that nothing verifies a value on that path- docs:
assertReplyParam()says that only a non-string third argument is refused. A string cannot be told apart from a failure message and is taken as one, soassertReplyParam($reply, 'key', 'a value')asserts no more than that the key exists - the previous wording said any value there was refused- docs:
dispatch()says that a non-GETadmin dispatch writes a realxf_admin_logrow, from XenForo's own admin controller, for anything that is not aGETor aHEADand does not answer with aReroute. The row carries the route path and the input the test sent, of which only_xfTokenand keys containingpasswordare masked, and a class extension on the admin log repository runs with it.UsesDatabaseTransactionsrolls the row back- docs:
UsesDatabaseTransactionssays that atearDown()which cleans up runs even whensetUp()threw, so it has to check what it is about to act on - and thatXF\Util\File::deleteDirectory()resolves a relative or empty path against the XenForo root, so an unset property reaches it as the forum root and deletes the installation. The guard to write instead is given- docs:
createUserAccount()says the account inherits the forum'sregistrationDefaultsoption - eight columns on the option record and five on the privacy one - so what a test can assert about a "normal" member follows the forum under test. Both ways to set what the test needs are given
5.25.1 (2026-10-14)
renderReply()renders a reply whose template name already carries a type, rather than prepending the dispatch type and failing. XenForo's standard delete plugin answers withpublic:delete_confirmfor an admin controller as well as a public one, because the control panel reuses that template - so an admin delete confirmation was refused withTemplate name 'public:delete_confirm' contains invalid characters, which named nothing about the type. A bare name still takes its type from the dispatch
5.25.0 (2026-10-13)
assertNoUnresolvedPhrases()reports a phrase key that ends at the prefix it was given. Every key XenForo structures for an add-on puts the add-on's own id last - an option group's title isoption_group.<groupId>- so a key is often the prefix and nothing more, and those were passing silently. A dotted segment in front of the prefix is now reported as part of the key, so the failure names a key that can be searched for rather than its tail- docs:
assertNoUnresolvedPhrases()lists the keys XenForo structures and says that the bare add-on prefix is the wrong argument for an admin option page. With development mode on, XenForo displays each option's id as ordinary text, so every option on the page is reported whether or not its phrase resolved - pass the keys instead. The ids are displayed only in development mode, so a bare prefix there passes on one install and fails on anotherassertNoTemplateErrors()takes a closure as its filter, given each error XenForo recorded. An add-on whose only markup is a template modification of a core template raises its errors while rendering a template somebody else owns - and so does a sibling add-on, so no template name tells them apart. A string filter still matches the template name and is never called, whether or not it happens to name a PHP function; anything that is neither a string, a list of strings nor a closure is refused- docs:
fakesSimpleCache()documentsgetSimpleCache(), and its example reads the fake back through that rather than out of the container - the accessor refuses when the fake was never installed, where the container hands back the forum's real cache. Both spellings ofassertSimpleCacheEqual()andassertSimpleCacheNotEqual()are listed- docs: the README's helper list names
assertNoUnresolvedPhrases(),renderRawReply(),renderJsonReply(),renderBbCode()andtextOf(), which were documented inDOCS.mdand missing from the list a reader uses to decide what the package covers. ItsrenderTemplateWithoutModification()entry carries the way to pair it with a dispatch- The refusals on the rendering assertions print the call they want.
assertSee(),assertDontSee()andassertSeeInOrder()take the escaping flag third and the failure message fourth, whileassertSeeText()andassertDontSeeText()take no flag at all - so a value in the wrong slot is answered with that assertion's own argument list rather than with a description of it- docs:
assertConsoleCommandsLoad()says what it returns - the classes XenForo would list as commands, which is fewer than the walk loaded. A file the runner skips, such as an abstract base beside the commands or a class that is not a SymfonyCommand, is loaded and left out of the list, so an assertion over the whole list counts commands rather than files- docs: add-on isolation does not filter content type handlers. They come from the database, so every installed add-on's are registered whatever
$addonsToLoadnames, and an operation that iterates every content type reaches a handler whose class extension was not applied. It fails inside XenForo's own code withCall to undefined method, naming neither isolation nor the add-on - narrow the operation, or name the add-ons whose content types the test reaches- docs:
$addonsToLoadcannot add a class extension to a class already resolved in the process, as well as being unable to remove one. A test class naming more add-ons than its siblings gets those extensions only when it happens to run first, so the list belongs to the whole suite rather than to a test class- docs: an id in
$addonsToLoadthat matches no installed add-on is ignored rather than refused, which is how to ask for no add-ons at all- docs:
dispatch()notes that a reply follows the forum's own configuration as well as the test's fixtures - a forum'slist_date_limit_dayskeeps older threads out of its thread list, andno_date_limitin the input lifts it - and that a forum's canonical route carries a trailing slash- docs:
fakesRegistry()says what actually writes a registry row - core rebuilding one of its own caches, from a core entity's_postSave()or_postDelete()- so a suite can tell whether the recommendation applies to it, and why calling it anyway is still the recommendation. It also says that the fake intercepts writes without isolating reads, so an assertion that a registry key exists passes whether or not the code under test wrote it, and that nothing needs decaching for it to reach a consumer built earlier- A test class whose
$addonsToLoadwould extend a class the run has already resolved is now refused insetUp(), naming the class and the extension that will not reach it. The resolution is cached for the whole process and the proxy XenForo aliases to reach an extension is aclass_alias()PHP cannot declare twice, so such a class got those extensions only when it happened to run first and otherwise exercised XenForo's own class with the extension's methods missing. Declare one$addonsToLoadfor the whole suite. Only widening is refused; a list that cannot remove an already-resolved extension is unchanged, andrequireClassNotExtended()stays the guard for a test that asserts on thatfollowRedirect()dispatches the route a redirect reply points at and returns the reply, so a test of a save can assert what the page the user lands on shows rather than stopping at the redirect. The route type is read from the url: the control panel has its own script, the api is routed through the public one behind a prefix, and link building honours the friendly-urls option for the public and api routers but not the admin one. One hop, as aGET; a url pointing at another host is refused- docs:
renderTemplateWithoutModification()says what still differs from arenderReply()of the same template -$xf.reply, which describes the page, comes from the reply and this renders without one. Compare againstrenderTemplate()where the template reads itglobalTemplateData()hands back thexftemplate parameter a render would see, so a test can assert on what atemplater_global_datalistener of yours contributed. XenForo fires that event from insidegetGlobalTemplateData(), which is why nothing else shows that the listener is registered with the class, method and signature XenForo calls. Passing a reply adds thereplyentry describing the page. Calling XenForo's method directly instead answersstyle => null, which is the state a template reading$xf.stylelogs an error forrunConsoleCommand(),assertConsoleCommandLoads()andassertConsoleCommandsLoad()refuse a command name carrying whitespace or braces. Symfony validates a command name against colons and nothing else, so a name with an argument placeholder written into it is accepted and the command runs - butcmd.php listprints the name as written, the command is reachable only as an abbreviation of itself, and a second command sharing that prefix makes both ambiguous. Declare arguments and options withaddArgument()andaddOption()instead
5.24.1 (2026-10-12)
- docs:
renderTemplateWithoutModification()names the way to pair it with a dispatch - pass the reply's own parameters, which is whatrenderReply()renders the template with, so the two renders differ by the modification and nothing else- docs:
createUserAccount()says that saving a user is the most contended write this package makes and that a transaction does not make it reliable. Two tests creating users against one database can deadlock, namingxf_user_group_relation; the mechanism and the alternatives were already underUsesDatabaseTransactions, where a reader meets them only after the failure
5.24.0 (2026-10-12)
- rendered templates are given a style.
$xf.stylewas null in every render, because only a real request sets one - so a core template calling a method on it logged an error and rendered nothing for that block, and a test could assert that markup was absent and be right for the wrong reason. Ten public and two admin templates read it on a stock installassertNoUnresolvedPhrases()searches the text a page displays rather than the whole markup. An add-on's prefix belongs in a form field's name, an id, a label'sforand a CSS class, so a phrase that resolved perfectly well was reported as having failed - XenForo's resource standards require that prefix on a column added to a core table, and a field is named after its column. A phrase that failed to resolve inside an attribute is no longer seen; assert on that one directlyrenderTemplateWithoutModification()renders a template with one of its template modifications left out, so a test asserting on inserted markup can show the modification is what inserts it. Nothing is written and the modification is left enabled, so it is safe on an install other suites are running against. It refuses a modification that does not exist or is disabledassertExceptionLogged()andassertExceptionNotLogged()name their second parameter$filterrather than$callback, which described only one of the forms it accepts. The value and its forms are unchanged, and a positional call is unaffected; a call passing it by name must be updatedassertExceptionNotLogged()is documented as taking a string the logged message must contain as well as a callback. Narrowing it denies only the exceptions matching the filter rather than all of that class, and a count is not accepted there - useassertExceptionLoggedTimes()- docs: an empty
$permissionsgiven toactingAs()grants nothing rather than denying everything, so an account with a real permission combination - anything fromcreateUserAccount(), or a user already in the forum - keeps what that forum grants it. A test asserting such a member is refused something needssetVisitorPermissions($user, [])- docs: eleven core public controllers do not require
general.view, account and login among them, so a dispatch that succeeds without granting it is not evidence that the next route will
5.23.1 (2026-10-12)
- docs: the upgrade note for 5.23.0's
REQUEST_METHODrefusal names the three helpers it applies to, and no longer offers a search for the key as the way to find affected tests. Building anXF\Http\Requestand swapping it into the container is unaffected, and there the key is how the method gets set - so the search finds tests that are correct and must be left alone, and the remedy it gave would have removed the method from them- docs:
UsesDatabaseTransactionsnamesxf_member_statas a second row a test that creates a user contends for, alongsidexf_user_group_relation. Saving anXF:Userempties the cachedstaff_membersstatistic and clears its expiry, which XenForo's own cron then rebuilds, andfakesRegistry()does not cover it. Includes the condition - the write happens for an ordinary member, not only for a member of staff - and how to mock the repository that performs it- docs:
assertExceptionLogged()states that the string form matches the prefix passed to\XF::logException()as well as the exception's own message, in the bullet describing that form and in the method's docblock. It was said only in the paragraph about the callback
5.23.0 (2026-10-12)
- a
REQUEST_METHODvalue in$serveris refused rather than ignored, bydispatch(),dispatchWithoutCsrfToken()andcallAction(). The request method comes from the$methodargument, which also decides whether a CSRF token is minted, so a method supplied through$servercould only have produced a request XenForo refuses - the value was discarded and the request sent with the method the argument named. A test asserting on a view could pass against a method it had not asked forassertNoUnresolvedPhrases()searches the page parameters the render set as well as the HTML.<xf:title>,<xf:description>,<xf:h1>and<xf:pageaction>compile to a write into the templater rather than to markup, so a phrase that came out as its own key in one of them was nowhere in the string the assertion was given. The failure names which surface each key came fromassertSee(),assertDontSee(),assertSeeText(),assertDontSeeText()andassertSeeInOrder()refuse an empty value, andassertSeeInOrder()refuses an empty list. An empty value is found at position 0 of anything, so a positive assertion could not fail and a negative one could not passassertExceptionLogged()'s$callbackis documented as accepting a string, which it has since 5.17.0. The parameter carried two docblocks, the older of which said otherwise- every assertion in
InteractsWithTemplatestakes an optional failure message last, prefixed to what the assertion found rather than replacing it -assertSee(),assertDontSee(),assertSeeText(),assertDontSeeText(),assertSeeInOrder(),assertNoUnresolvedPhrases(),assertNoTemplateErrors()andassertTemplateModificationApplied(). The five output assertions took no message at all, and a third argument passed toassertSeeText()orassertDontSeeText()was discarded without a word- a failure message written where
assertSee(),assertDontSee()orassertSeeInOrder()take their$escapeflag is refused, and a bool written whereassertSeeText()orassertDontSeeText()take their message is refused. A non-empty string is merely truthy, so a message in the flag's place left the assertion behaving exactly as it does by default with the message gone- a failure message given to
assertNoUnresolvedPhrases()no longer replaces the list of unresolved keys, which is what says which phrases failed and on which surface- docs: saving an
XF:Userrow rewrites the user's rows inxf_user_group_relation, and the delete that precedes the insert locks the index gap above the last row for the rest of the transaction. Two tests creating users concurrently deadlock there on different user ids.createEntity('XF:User')reaches it as well ascreateUserAccount(), andfakesRegistry()does not cover itbuildDispatchRequest(),routeTypeConfig(),installGlobalTemplateData()andbuildVisitor()carry their own documentation again, having each been left describing the method below them- docs: call
fakesRegistry()from thesetUp()of any class that creates or deletes content underUsesDatabaseTransactions, not only in a test about the registry- docs: on XenForo 2.3.0 an entity keeps its cascaded saves after it is saved, so a parent a service created re-saves its children and setting a value on one of them throws
Attempted to set '<column>' while a save was pending without forceSet. Re-fetch the parent or callremoveCascadedSave();reset()is not the remedy
5.22.0 (2026-10-11)
- a second
fakesHttp()orfakesHttpByUrl()in the same test reaches the clients the first one covered. Each fake built its own handler stack, and a Guzzle client holds the stack it was built with - so a client an add-on's sub-container had already built, or the shared client a test kept in a variable, went on using the first fake's queue. The second fake installed responses nothing read, and the first queue, by then drained, answeredOutOfBoundsException: Mock queue is emptyfrom inside the code under test. The stack for each client kind is now built once per test and its handler replaced, which also means changing fromfakesHttp()tofakesHttpByUrl()within a test takes effect everywhere- docs: running the code under test twice in one test with the responses queued afresh, and the one case that still cannot work - a fake installed after the client was built with no fake in place
5.21.1 (2026-10-11)
- a failed
assertSee(),assertDontSee(),assertSeeText()orassertDontSeeText()no longer prints the rendered markup. PHPUnit's own string assertions print the whole subject, so a miss against a page was thousands of lines of escaped HTML with the value it looked for at the end. The failure now names that value, the escaped form where escaping changed it, and the length rendered;assertDontSee()adds the offset of the first match and the text either side of it- docs: markup a template sets through
<xf:pageaction>,<xf:title>,<xf:h1>or<xf:description>is not in the rendered HTML, a template modification inside one of those tags included.assertDontSee()andassertNoUnresolvedPhrases()given the HTML therefore pass without checking it - read the value withpageParam()and assert on that- docs: an id the code under test stores against the visitor resolves to whatever real account holds that id when a relation loads it back, since
actingAsMember()builds a user in memory withuser_id1. UsecreateUserAccount()where the page reads the account back
5.21.0 (2026-10-10)
runRunnerStep()runs one step of a XenForo multi-part runner - a clean-up, merge or content-change service - to completion, for testing an add-on that contributes work to a step without running every other step as well. A step's return value is a resume offset rather than a result, so it loops until the step reports it has finished- the
UsesDatabaseTransactionscheck for a transaction that did not survive stays quiet when the test has already failed, so a query that failed because the server rolled the transaction back is not reported as something having committed it- the same check's message names both causes: something committed the transaction, or the server rolled it back
- docs: the data registry is the row a test is most likely to contend with the live forum for, and what to do about it
5.20.0 (2026-10-10)
assertNoTemplateErrors()takes the prefix of the templates you own, or a list of them, so it can assert on your own templates on a development forum where a sibling add-on's template modification raises errors on every render. The filter matches the template nameassertNoUnresolvedPhrases()accepts several prefixes, for an add-on whose older phrases predate its naming convention, and reports every unresolved key rather than failing on the first prefix- docs: the filtering example under
assertNoTemplateErrors()was aTypeError- each error XenForo records is an array, not a string- docs: how to prove
assertNoUnresolvedPhrases()can fail, and why a phrase-level mutation appears to do nothing- docs: the README's install example requires
^5.20
5.19.0 (2026-10-09)
- a link a controller builds during an
adminorapidispatch()comes from that type's router, so the url of a redirect is anadmin.phpone and can be asserted.XF\Mvc\Controller::buildLink()asks for no type and XenForo hard-wires the untyped router to the public one, which a realXF\Admin\Appoverrides - so a redirect carriedindex.phpand a route the public router does not know, andassertReplyIsRedirect($reply, $url)could not be given a true urldispatch()builds its request the way the forum serves that route type:admin.phpfor an admin route, the public script with anapi/prefix for an api one, and the url form the Use full friendly URLs option selects. A dispatch on a forum with that option on was answered with a redirect to its own canonical url rather than the page - the board index was the clearest casecreateUserAccount()creates a member with the profile, option, privacy and authentication records a user has in practice.createEntity('XF:User')writes thexf_userrow alone, which a XenForo page reading one of those relations cannot displayactingAsAdministrator()is the three lines an admin test opens with: a member withis_admin, an administrator record with the given permissions, and ordinary permissions where they matter- docs:
createEntity('XF:User')builds a bare row, and what to use instead- docs: build the url a redirect assertion expects with the router and match it as a substring - a redirect's url may be absolute or carry a fragment, and a public or api one changes shape with the Use full friendly URLs option, where an admin one does not
5.18.1 (2026-10-09)
assertConsoleCommandsLoad()no longer fails on a file XenForo's own runner skips - an abstract base beside the commands, as core's ownCli/Commandhas, a trait or interface file, or a class that is not aCommand. None of them is listed by XenForo or affectscmd.php, and none appears in the returned list. A class that cannot be loaded still fails, since that is the case that stopscmd.phpfor every add-on on the forumassertConsoleCommandsLoad()refuses when the files it finds declare nothing XenForo would list, so the skips cannot add up to an assertion over nothing- docs: asserting the whole list of command classes, which also fails when one is added with no test
- docs: confirming a console command with
['inputs' => ...]does the work against the forum the suite points at - a command built on XenForo's rebuild base class runs its job in its own process rather than queueing it, and a transaction is not a reliable guard
5.18.0 (2026-10-08)
runConsoleCommand()takes['inputs' => [...]], the answers to a command that asks questions, so the confirmed path of a command that confirms before acting is testable. Supplying answers with['interactive' => false]is refused, since Symfony would answer every question with its default and ignore them- docs:
['interactive' => false]answers every question with its default, which stops a command at its confirmation and writes nothing - the safe test for a destructive command- docs:
assertNoTemplateErrors()describes the forum rather than the add-on on an install shared with other add-ons, because template modifications are compiled into the shared template cache whatever$addonsToLoadsays. Assert on the errors naming templates you own- docs: a grep for a value passed where a reply assertion's failure message goes finds only the ones written as a literal; the refusal finds the rest
- docs: the README's install example requires
^5.18
5.17.0 (2026-10-08)
assertConsoleCommandsLoad()andassertConsoleCommandLoads()assert that the console command classes an add-on ships are ones XenForo can load. A class that cannot load takescmd.phpdown for every add-on on the forum, since XenForo loads them all simply to list them, and nothing else in a suite loads them at all. The walk refuses when it finds nothing to checkassertExceptionLogged()accepts a string the logged message must contain, as well as a count or a callback- docs: the README's install example requires
^5.17
5.16.0 (2026-10-08)
requireClassNotExtended($identifier)skips a test unless the given class resolves unextended, for a test whose point is that an add-on's extension is absent- docs:
$addonsToLoadcannot remove a class extension already resolved in the process, because the XFCP proxy XenForo aliases cannot be undeclared - so such a test passes on its own and exercises the extension in a full run- docs: upgrading from before 5.15.0 means checking an add-on's own testing notes as well as its tests - prose explaining why a write path needed
callAction()does not fail the way an assertion does- docs: the
league/flysystempin and the advisory it carries are explained where a contributor will meet them, including what would have to change for the pin to come off- docs: the README's install example requires
^5.16
5.15.0 (2026-10-08)
- a non-GET
dispatch()carries a CSRF token the request has a matching cookie for, so an action behind XenForo's check is reachable with itspreDispatch()guards intact. A write path no longer has to be tested throughcallAction(), which skips those guardsdispatchWithoutCsrfToken()sends no token, for asserting that the check refuses. A token passed as_xfTokenin the input is never replaced, so an invalid one can be tested tooassertReplyParamSame($reply, $key, $expected)compares a reply parameter.assertReplyParam()has no expected-value argument - its third argument is a failure message, and a string passed there is taken as one, so the assertion passes whatever the parameter holds- docs: a render resolves phrases from the database, so editing
_output/phrases/proves nothing - change the phrase through its entity, before the render under test- docs:
UPGRADING.mdnames the command that lists everyPOSTdispatch in a suite, since such a test changes meaning rather than failing- docs: the README's install example requires
^5.15
5.14.0 (2026-09-30)
- a reply assertion refuses a value where its failure message goes.
assertReplyParam($reply, $key, $expected)has no expected-value argument, so the value was taken as the message and the assertion passed whatever the reply held. Read the value withreplyParam()and assert on it insteadfakesMail()can make sending fail:$transport->failWith($exception), and$transport->sendsSuccessfully()to lift it. XenForo's mailer catches it, logs it and answersfalse, so pair it withfakesErrors()mockFinder()refuses a name XenForo cannot turn into a finder class, naming the name and theVendor:Nameform it wants, rather than failing from inside XenForo- docs:
assertExceptionLogged()'s callback is handed the logged entry array, not the throwable- docs: build a mail after installing
fakesMail()- one built before it sends through the forum's real transport while the fake captures nothing- docs: a suite with no
$addonsToLoadcan die at boot where another add-on vendors a different PHPUnit, and the fatal is written to the forum's error log- docs: the README's install example requires
^5.14
5.13.1 (2026-09-30)
composer updateresolves again. Everyleague/flysystemrelease up to 3.35.2 carries a low-severity advisory and Composer blocks an affected package by default, so nothing that needs flysystem 1.x - which is what XenForo supplies, and whatswapFs()needs - could be installed.composer.jsonnow accepts that one advisory by id, with the reason recorded wherecomposer auditprints it- the
league/flysystemdev floor is^1.1.4, above the range of the critical advisory that is patched, so that one stays enforced rather than ignoredrunConsoleCommand()'s type error names what the argument resolved to rather than the argument- docs: what to put in your own
composer.jsonto installleague/flysystem-memoryforswapFs()
5.13.0 (2026-09-30)
callAction()refuses a controller named by an add-on's extension of it, and names the class to pass instead. Naming the extension worked only when something earlier in the run had resolved the class being extended, so the same call passed in a full suite and failed under--filterwithClass "...\XFCP_..." not foundrunConsoleCommand()refuses a command name where a class name is wanted, rather than failing withClass "my-addon:do-thing" not found- the shipped
phpunit.xmlno longer setsfailOnPhpunitDeprecation, which PHPUnit 10.5 does not have - with it, the configuration fails validation andfailOnWarningthen fails every run on the oldest supported version. Re-copyphpunit.xml- the
phpunit/phpunitfloor is^10.5, and thenesbot/carbondev floor^2.50. The shippedphpunit.xmlsetsfailOnDeprecationandfailOnNotice, which PHPUnit 10.0 to 10.4 do not have, and Carbon 2.0 cannot load on PHP 8.3, so neither old floor was usable- docs: name the controller your add-on extends, not your extension of it
- docs:
$db->emptyTable()issues aTRUNCATE, so it commits a test's transaction- docs: the README's install example requires
^5.13
5.12.0 (2026-09-30)
UsesDatabaseTransactionsalso fails the test when the code under test ended the transaction rather than committing it.rollbackAll()andcommit()both do, and XenForo callsrollbackAll()on its own error paths - the job manager when a job throws - after which everything written, including what XenForo writes itself, is permanent. The commit check could not see this, because nothing had been committed- docs: what that means for a test that runs a failing job through the job manager
- docs: the README's install example requires
^5.12
5.11.0 (2026-09-29)
- a class extension added during a test is forgotten when that test finishes, so a later test resolving the same class gets the base class again. The cache outlives the application, so such an extension previously applied for the rest of the run and which tests failed depended on their order
- extending the same class a second time in a run is refused whether or not the cache still holds it, since the proxy XenForo aliases cannot be declared twice in one process
- docs: the README's install example requires
^5.11
5.10.0 (2026-09-29)
- the extension cache is keyed by the class XenForo will actually extend, rather than by the spelling it was asked for. A class reached by more than one name - with a leading backslash, or under the name XenForo 2.3 renamed it to - was extended once per spelling, and the second attempt failed with
Cannot declare class ... XFCP_... because the name is already in use. It surfaced in a full run only, since each test builds its own extension while the cache is sharedExtension::addClassExtension()evicts the cached resolution for that class, so an extension added during a test applies even when an earlier test resolved the class unextended. Adding one to a class already extended in the same run throws, because the proxy PHP needs cannot be declared twice- docs: a data provider runs before XenForo boots, so a class constant from XenForo or its vendor tree is not available in one
- new
runConsoleCommand()runs a console command against the booted application and returns Symfony'sCommandTester, for asserting on its output and exit codesetStaticProperty()restores the original value when the test finishes, as the otherset*helpers do. A static outlives the application, so a value set in one test was read by every later one- docs: the README's install example requires
^5.10
- docs: for an endpoint that opts out of
preDispatch()'s checks, the response code says which layer refused - 405 from the action's ownassertPostOnly()means the checks let the request through, where 403 is one of the checks refusing it
5.9.0 (2026-09-27)
dispatch()takes a request method as its fifth argument, defaulting toGET. For a controller that deliberately opts out of the CSRF check - a server-to-server endpoint overridingcheckCsrfIfNeeded()- this is the only way to reach its access checks, sincecallAction()skipspreDispatch()entirely- new
renderJsonReply()renders a reply through the JSON renderer and returns the decoded document, for a view that shapes its own response inrenderJson(). It refuses a view that cannot answer JSON rather than returning XenForo's own well-formed but empty document- docs: an entity double takes its expectations on
get(), sinceEntity::__get()delegates to it and an expectation on the magic method never applies; array access needs its own expectation- docs: the isolated add-on's own code event listeners do run, including while the container builds something the test is not otherwise interested in
- docs: the README's install example requires
^5.9
5.8.0 (2026-09-26)
dispatch('')reaches the board index instead of returning a permanent redirect to it. The request uri carried a trailing?, which does not match the canonical url XenForo compares it against. An empty route with input no longer gets a stray&eitherdispatch()andcallAction()put one of XenForo's own application classes in\XF::app()for the type being dispatched, sharing the test application's container, and fire that type's setup event -app_pub_setup,app_admin_setuporapp_api_setup- once per test. A container key an add-on registers there now exists, and a listener gated on$app instanceof \XF\Pub\Appruns. The newsetAppClassType()does the same before a render that needs itmakeEntity()throws when a value it was given did not land. XenForo verifies many columns as they are set - a unique key already in use, an invalid email - and records a failure rather than throwing, which left the entity quietly missing the value.setTrusted()sets one anyway- docs: the README's install example requires
^5.8
5.7.0 (2026-09-26)
- new
renderBbCode()renders BB code and returns the HTML, for asserting on part of the output whereassertBbCode()'s exact comparison cannot be used- new
mockFind()decides what\XF::em()->find()returns for one id, stubbing the finder calls it makes and clearing the entity cache it reads first- docs:
assertBbCode()'s example expected unwrapped output -htmlandemailHtmlwrap in<div class="bbWrapper">,simpleHtmldoes not - and it compares exactly, so a tag rendering through a template is better rendered and asserted on withassertSee()- docs: the
swap()example called the value the container returns, which is already the object- docs:
\XF::em()->find()goes through a finder, so it needsmockFinder()rather thanmockEntity(), and it consults the entity cache first- docs:
assertNoTemplateErrors()covers a BB code render, which uses the same templater- the scaffold's
$addonsToLoadcomment says to name your own addon, and what an empty value costs on a forum where another addon vendors its own PHPUnit- docs: the README's
build.jsonexamples no longer runcomposer installwithout a--working-dir, which stripped the dev dependencies from the addon you are developing in; they remove.phpunit.cache, which otherwise ships inside the release; and they userm -fv- docs: an
AUTO_INCREMENTcounter is not rolled back with the transaction- docs:
setTrusted()must not be used on the id of a visitor built byactingAs()- docs: a route requiring registration refuses a guest with the
loginview and a 403, not an error- docs: a test needing a real table cannot use
UsesDatabaseTransactions, with the pattern to use- docs: raising
config.platform.phpto install this stops Composer guarding your runtime dependencies' own PHP floor, and an addon's dev dependencies are visible to other addons' suites- docs: the README's install example requires
^5.7
UsesDatabaseTransactionsnow fails the test when something has committed its transaction, rather than rolling back nothing and leaving every row the test wrote in the database. MySQL commits implicitly on any DDL, which includes theTRUNCATEXenForo runs when it compiles a template - as it does on a development install for any template whose_output/copy has not been importeddispatch(),callAction()andrunJobToCompletion()no longer write XenForo'sautoJobRunregistry row when they run deferred work. UnderUsesDatabaseTransactionson MariaDB 11.8 or later that write could fail withRecord has changed since last read, on some runs and not others, when the forum's own traffic or cron had touched the row- docs:
renderRawReply()on a view that streams its output returns anXF\Http\ResponseStream, read withgetContents()- docs:
UsesDatabaseTransactionsputs the test in a race for any row the live forum also writes
5.6.0 (2026-09-23)
callAction()takes an optional$filesargument, so an action reading$this->request->getFile()can be tested with a file. Build the entry with the newuploadedFile()oruploadedFileFromPath(), which write a real temporary file and remove it when the test finishes- new
assertNoUnresolvedPhrases()asserts that no phrase rendered as its own key, ignoring the template names the templater embeds for an administrator. Named so it does not collide with anassertPhrasesResolved()of your own- new
renderRawReply()renders a reply through the raw renderer and returns the response, with its body, for an action whose view builds its output inrenderRaw()- docs:
callAction()'s action argument is the controller's own action - a route'saction_prefixis not applied- docs:
UsesDatabaseTransactionscannot roll back a file, including what an entity compiles into the code cache when it saves- docs:
getHttpRequests()andgetHttpHistory()read the requests a test sent, and the history is kept for the whole test rather than reset by a secondfakesHttp()- docs: the README's install example requires
^5.6
5.5.0 (2026-09-23)
fakesHttp()andfakesHttpByUrl()now also fake clients built with$app->http()->createClient(), which an add-on uses when it needs its ownbase_uri, headers or timeouts. Previously only the sharedclientandclientUntrustedwere faked, so those requests were sent for real. A client created before the fake is installed still uses the real handler- the fake reaches those clients while
fakesEvents()is active as well- docs: on a development install, a template with an
_output/copy renders from that copy, and a template changed only in the database is replaced before it renders. Template modifications still needxf-dev:import- docs: the README's install example requires
^5.5
5.4.0 (2026-09-22)
renderTemplate(),renderReply()andrenderMacro()set thexftemplate parameter as XenForo does for a page, fromgetGlobalTemplateData(), so$xf.options,$xf.visitorand the rest are available to the template. It is rebuilt for every render, and anxfparameter the test set itself is left alone- a visitor a test set with
\XF::setVisitor()is cleared after the test, rather than carried into the next one- docs: the README's install example requires
^5.4
5.3.0 (2026-09-22)
- new
runJobToCompletion()runs a job until it completes and returns its finalJobResult. Each pass is a new instance built from the previous pass's data, and work queued with\XF::runOnce()runs after each pass. A failed result, a job that does not complete withinmaxPasses, and a job that does not exist throw aLogicException- docs:
callAction()accepts an action name containing slashes- docs:
UsesDatabaseTransactionsrolls back a nested commit even when the code then throws- docs: the README's install example requires
^5.3
5.2.0 (2026-09-22)
dispatch()andcallAction()take an optional$serverargument - request server values such asREMOTE_ADDR,HTTP_USER_AGENTandHTTP_REFERER, merged over the defaults.REQUEST_METHODcannot be changed this way- docs: on XenForo 2.3
Request::getFromSearch()always returns an empty string
5.1.0 (2026-09-21)
- new
callAction()calls a controller action directly with aPOSTrequest and returns its reply, for testing saves, toggles and deletes. It skipspreDispatch(), so neither the CSRF check nor the controller's permission check runs - usedispatch()to test those. A validation failure comes back as anErrorreply with its errors keyed by field, a reply thrown asXF\Mvc\Reply\Exceptioncomes back as that reply, aRerouteis followed, and work queued with\XF::runOnce()has run before the reply is returned- docs: the README describes running each test class on its own to find a test extending plain
PHPUnit\Framework\TestCasethat depends on an earlier test having booted XenForo
5.0.0 (2026-09-21)
- the framework now boots the application:
Hampel\Testing\TestCase::createApplication()reads$rootDirand$addonsToLoadfrom your test class, andtests/CreatesApplication.phpis no longer part of the scaffold. An existing copy keeps working. To use the framework's boot, delete that file and theuse CreatesApplication;line intests/TestCase.php. To keep a boot of your own, overridecreateApplication()and pass['xf-addons' => $this->addonsToLoad]toXF::setupApp()$rootDirand$addonsToLoadare declared onHampel\Testing\TestCase, with the scaffold's defaults- fix: add-on isolation now filters code event listeners. Previously every installed add-on's
app_setuplistener ran regardless of$addonsToLoad- fix: with
$addonsToLoadset, a class extension registered on a pre-2.3 class name -XF\Service\User\Loginrather thanLoginService- was not applied, and XenForo's own class ran instead. Also fixed in 4.3.2 and 3.0.8TestCasethrows aLogicExceptionwhen$addonsToLoadis set but the application was booted without it, as happens with atests/CreatesApplication.phpolder than 2.1.0- new
Hampel\Testing\App::isolatedAddOnIds()returns the add-on ids the application was booted with- new
Hampel\Testing\Extension::forAddOns()builds an extension carrying only the given add-ons' listeners and class extensions- the
Concerns\InteractsWithExtensiontrait has been removed; the application installs the filtered extension while it bootsmockDatabase()no longer requires the mock'sfetchAllto return an array- docs: a template error is written to the real
xf_error_log;fakesErrors()orUsesDatabaseTransactionsprevents it- docs: a test extending plain
PHPUnit\Framework\TestCasecannot load add-on classes unless an earlier test has booted XenForo
Breaking changes:
- with
$addonsToLoadset, listeners belonging to add-ons not named there no longer run. A test that relied on one - a container entry it registered, an option it set, a class it extended - needs that add-on added to$addonsToLoad- a suite that sets
$addonsToLoadwith atests/CreatesApplication.phpolder than 2.1.0 now fails with aLogicException. Delete that file and itsuselineConcerns\InteractsWithExtensionhas been removed. Remove anyuseof it from your own test classes- where the application is booted with a different add-on list than
$addonsToLoad, the list the application was given decides which listeners and class extensions are active
4.3.2 (2026-09-21)
- fix: with
$addonsToLoadset, a class extension registered on a pre-2.3 class name -XF\Service\User\Loginrather thanLoginService- was not applied, and XenForo's own class ran instead
3.0.8 (2026-09-21)
Backported from 4.3.2.
- fix: with
$addonsToLoadset, a class extension registered on a pre-2.3 class name -XF\Service\User\Loginrather thanLoginService- was not applied, and XenForo's own class ran instead
4.3.1 (2026-09-20)
A documentation release - nothing insrc/changed, so it cannot affect how a test runs. Two of the
entries below correct things the previous documentation stated as fact.
- docs:
spy()has never been documented. It has shipped in every release since 1.0.0 and appeared in neitherREADME.mdnorDOCS.md, so the only way to find it was to readsrc/Concerns/InteractsWithContainer.php.DOCS.mdnow carries aspysection besidemock, theREADMEplaces it betweenswap()andmock(), andintegration/SpyTest.phppins the documented behaviour - including that a spy answersnullfor every method it was not told about, which is the trade againstmock()and the part an example has to show- docs: add-on isolation does not filter code event listeners, and the documentation said it did.
XF\App::setup()firesapp_setupas its last step, while the filteredextensioncontainer key is installed later still - so Composer autoloading and class extensions are filtered and listeners are not, and every installed add-on'sapp_setuplistener runs whatever$addonsToLoadsays. Measured on a development forum carrying 13 of them: all 13 listener classes loaded, and the container kept the entries they registered.README.mdgains a "What isolation does not cover" note, because a consumer meeting this sees a failure inside an add-on they never listed, which reads as anything but a framework limitation. Fixing it needs a change to the boot sequence and is deferred to the next major version- docs: upgrade notes for every version back to 2.1 move out of
README.mdand into a newUPGRADING.md, which ships in the package. They had been duplicated between the README and the resource post, and a second copy of an upgrade instruction is how someone ends up following a stale one. Only v4.0 and v2.1 require action from you; the file says so at the top- docs: the
failOnRiskyandfailOnDeprecationexplanation moves into the installation section, beside thephpunit.xmlit describes. It had lived only under "Upgrading", where a first-time installer would never meet it- docs:
README.md's section 12 no longer presentsapp.classTypeas a testability workaround - it is XenForo's own idiom, used four times inXF\App.phpitself- docs:
README.md's "Compatibilty" heading is spelled correctly- changelog: the 3.0.7 entry, brought across from the
3.xbranch
4.3.0 (2026-09-20)
- fix:
dispatch()never ran the work a controller deferred. XenForo drains its run-once queue inXF\Mvc\Dispatcher::dispatchLoop(), anddispatch()resolves reroutes in its own loop instead - deliberately, becausedispatchLoop()catches every controller exception and rolls the transaction back with it. So anything queued through\XF::runOnce()was simply dropped, and an entitypostSave()rebuilding a cache is the common case. The queue now runs after the dispatch, rethrowing, so a failure there fails the test rather than being logged and swallowed- fix:
\XF::$runOnceis a static nothing reset, so a closure left in the queue by one test ran during the next one, bound to the application the first test had. It is discarded in teardown- fix: a template that failed while rendering came back as an empty string. XenForo's templater catches everything a template does wrong - a PHP error, a macro or an included template that does not exist, an exception part way through - logs it, and carries on. Where that left nothing, the failure arrived as output rather than as a failure: an
assertDontSee()passed on it whilerenderTemplate()'s own guard found the template present and said nothing. It now throws, naming what went wrong. A template that throws is also caught when$config['debug'] = true, where XenForo renders the exception as markup- a render that errored and still produced markup is returned unchanged, deliberately: it is much the commoner case - 103 of 400 core templates rendered with no parameters raised an error on 2.3.12, and 101 of those still produced markup - and failing them would turn working suites red on a patch release. New
assertNoTemplateErrors()is the opt-in for the stricter guarantee- new:
renderMacro()renders one macro out of a template, with the arguments a caller would pass it - for markup that lives in a macro, or where rendering the whole template would need parameters the test has no reason to build. A macro that does not exist renders as an empty string, so that is refused too- new:
pageParam()reads back what a rendered template set with<xf:title>,<xf:description>,<xf:h1>or<xf:pageaction>. None of them appear in the template's own output - the markup around them belongs to the page wrapper - so reading one back is the only way to assert on itassertReplyIsRedirect()takes atype,permanentortemporary. A redirect reply carries no http status of its own:getResponseCode()answers200either way, because the code is chosen later by the renderer, which maps permanent to a301and temporary to a303. So a test asserting the code could not tell the two apart, and the failure message no longer prints that200beside a redirect as though it meant something- every reply assertion takes an optional
messageas its last argument, added to the failure. In a test that dispatches several routes the description says what the reply was but not which route produced it, which is the part you needassertReplyIsError()'s third parameter is now named$errorText, since$messageis the failure message everywhere else in the family. Positional calls are unaffected; a call passing it by name has to changemockery/mockeryis now^1.6. The old^1.0was never tested and could not work: Mockery 1.0, 1.1 and 1.2 declarephp >=5.6.0, so Composer installs them onto a supported PHP and the generated mock code is then invalid -Cannot use "parent" when current class scope has no parent, a fatal before any test runs- docs: the
fakesJobs()example could not run. It showed the truth-test callback receiving a job object and calling$job->getData(), where what the fake passes is the array it recorded - the parameters are$job['execute_data'], and no job is ever constructed. The same example also asserted a job name it had not queued. Both fixed, andassertJobQueued()now documents what it matches on: the class string exactly as the caller named it, so'XF:FileCleanUp'and\XF\Job\FileCleanUp::classare both valid and do not match each other- docs: a public route needs the visitor to hold
general.view, which a built visitor does not, so a publicdispatch()refuses with a403until the test grants it- docs: this framework boots the base
XF\App, so your own code asking which application is running takes the other branch - atemplater_global_datalistener opening withif ($app instanceof \XF\Pub\App)never runs its body here- docs: a class extending a XenForo class cannot be declared at test file scope. PHPUnit loads every test file while building the suite, before XenForo has booted, so the parent does not exist yet and the whole run stops with
Class "..." not found
3.0.7 (2026-09-20)
Backported from 4.3.0.
mockery/mockeryis now^1.6. The old^1.0was never tested and could not work: Mockery 1.0, 1.1 and 1.2 declarephp >=5.6.0, so Composer installs them onto a supported PHP and the mock code they generate is then invalid there. Mocking a plain class still succeeds, which is what made the constraint look sound - it is mocking an interface that fails, withCannot use "parent" when current class scope has no parent, before the first test runs
4.2.0 (2026-09-19)
- new:
renderTemplate()renders a template to HTML with no web server, andrenderReply()renders the one a dispatched reply named, withassertSee(),assertDontSee(),assertSeeText(),assertDontSeeText(),assertSeeInOrder()andtextOf()to assert on the output. This covers the two checks a reply cannot: that a template modification applied, and that a phrase resolved rather than rendering as a raw key. The expected value is escaped by default, matchingXF::escapeString()- a template name XenForo cannot find - a missing title, the wrong type, or a type that does not exist - renders as an empty string with no error, so an
assertDontSee()against one would pass while testing nothing.renderTemplate()throws instead, and it requires thetype:titleform rather than guessing the type- new:
assertTemplateModificationApplied()asserts a template modification is matching something, by reading its apply count. XenForo logs a modification that matches nothing as statusok, so the status cannot tell you one has silently stopped applying - only the count can- rendering covers the template, not the whole page: navigation, header and footer come from XenForo's own app classes rather than from the template
4.1.0 (2026-09-18)
- new:
dispatch()runs one of your routes the way XenForo does and returns the reply its controller produced, withassertReplyIsView(),assertReplyTemplate(),assertReplyViewClass(),assertReplyParam(),replyParam(),assertReplyIsRedirect(),assertReplyIsError(),assertReplyIsMessage(),assertReplyIsApiResult()andreplyApiResult(),replyErrors()and an optional message argument toassertReplyIsError()to assert against it. Parameters the route reads are passed as the third argument - the router takes a route path whole, so criteria cannot go in it. Public, admin and api routes are all reachable and reroutes are resolved for you. This is the only way to cover an action's own access checks: a controller invoked directly never runspreDispatch(), which is where XenForo's ownxf-make:controllerstub puts them, so an action tested that way is tested with its authorisation skipped- new:
setVisitorAdminPermissions()grants admin permissions to a built user, by giving it the administrator recordbuildVisitor()withholds. XenForo reads admin permissions from the user'sAdminrelation rather than from the permission combinationsetVisitorPermissions()writes, so they need their own helper- new:
actingAsApiKey()runs api dispatches as a given api key, and restores\XF::$apiKeyafterwards - a static nothing else resets, so a key set by hand leaks into every later testdispatch()does not render the page, so asserting on HTML - that a template modification applied, or that a phrase resolved rather than showing a raw key - is not supported yet- a
POSTroute dispatches but only as far as the refusal: XenForo asserts a CSRF token inpreDispatch()for anything that is not aGET, so an action opening withassertPostOnly()returns a 405. Sending aPOSTis not supported yet- docs:
DOCS.mdstill said you cannot unit test code which callssave()on an entity, and offeredmockEntity($name, false)as the way round it.UsesDatabaseTransactionsanswered that in 4.0.0 and the README was updated at the time; this file was missed
We use essential cookies to make this site work, and optional cookies to enhance your experience.