Skip to content

Release candidate - #76

Merged
jwrosewell merged 61 commits into
mainfrom
rc
Nov 12, 2026
Merged

jwrosewell merged 61 commits into
mainfrom
rc

Conversation

@jwrosewell

@jwrosewell jwrosewell commented Nov 12, 2026 •

Copy link
Copy Markdown
Contributor

Releases what has collected on rc since the last release, #30, which merged on 4 November 2026 at 21:41 UTC.

This release moves PHP toward one behavior across every 51Degrees language. Each element it changes now follows the published 51Degrees specifications, which in turn follow the RFCs they cite, so that the same input gives the same answer in .NET, Java, Node.js, Python, PHP, Go, Rust and C. The on-premise extensions take the C library release 51degrees-cxx 4.5.105.

Before you upgrade

  1. A Flow Element of your own must name its Evidence. An element that did not override getEvidenceKeyFilter accepted every key and now accepts none, so $flowData->evidence->get() gives null for a key that no element accepts. Override getEvidenceKeyFilter and return a BasicListEvidenceKeyFilter that lists the keys the element reads (#41, #50).
  2. Reading value on an on-premise Property with no value throws. The C library now gives the message "None of the results contain a value for the requested property.", where it gave none, and AspectPropertyValue throws its message when value is read. Code that got null now gets an \Exception, so test hasValue before reading value (#46).
  3. The IP Intelligence Engine built when PHP starts always holds the whole Data File in memory. It used the Balanced profile by default and now always uses MaxPerformance, as Device Detection does, because a process manager forks its workers after the Engine is built and the other profiles shared the Data File's handles between workers. A php.ini FiftyOneDegreesIpIntelligenceEngine.performance_profile other than MaxPerformance now makes IpIntelligenceOnPremise throw, so remove that setting (#40).
  4. Four Device Detection php.ini settings read a bare true as on. FiftyOneDegreesHashEngine.allow_unmatched, use_predictive_graph, use_performance_graph and update_matched_useragent counted only "true" in quotes as on. A php.ini that wrote a bare true, on, yes or 1 got off and now gets on, which can change detection results, so check the four settings (#39).
  5. The Cloud Request Engine sends Evidence that no Flow Element accepts. It sends every Evidence value apart from the reserved names resource, license, licence and values, which it writes itself, so do not add Evidence that must not reach the cloud service. Each name is sent in the key's own letter case, so cookie.51D_Experiment is sent as 51D_Experiment, where it was 51d_experiment (#56, #65, #70).
  6. The JSON Builder needs a Sequence Element. JsonBundlerElement fails with the message "The JSON Bundler needs a sequence number. Place a Sequence Element before the JSON Bundler in the Pipeline." when there is none. PipelineBuilder::build() adds the Sequence Element first. PipelineBuilder::buildFromConfig() adds none, so a configuration file that lists JsonBundlerElement must list SequenceElement before it (#50, #69).
  7. The JSON sent to the browser changes. It gains a sequence entry that holds session-id and sequence, and loses the cloud entry, which held the cloud service's whole response. Every key is lower case apart from javascriptProperties. A nullreason is written only for a Property with no value, so false, 0 and an empty string no longer get one (#45, #60, #51, #57).
  8. Settings that were accepted now throw when the element is built. JavascriptBuilderElement throws an InvalidArgumentException for a protocol other than http or https, and for an object name such as window or document (#31, #53). A CloudRequestEngine resourceKey that is empty or only white space counts as absent (#58).
  9. The Device Detection extension no longer has a PHP proxy file. FiftyOneDegreesHashEngine.php is gone, because the extension registers its classes itself. Remove any require of that file, and enable the extension in php.ini, because nothing calls dl() to load it any more (#47).
  10. An Engine built with no Property names keeps the first Data File's Properties across every refreshData(). A Property that only a later Data File holds has no value until the Engine is created again, which for the Engine built when PHP starts means restarting PHP (#46).
  11. A 51Did with white space inside it, or with characters from both base64 alphabets, is refused with the status InvalidBase64. FodId::toStandardBase64 returns null for such a text, and its return type is now ?string (#38).
  12. _51DEGREES_CLOUD_ENDPOINT replaces FOD_CLOUD_API_URL, which still works in this release. The Cloud Request Engine and the 51Did client read their endpoint from _51DEGREES_CLOUD_ENDPOINT, and read FOD_CLOUD_API_URL only when the new name is unset or empty. When the old name is used, the Cloud Request Engine logs a deprecation warning through the Pipeline's logger, and the 51Did client writes the same sentence to the PHP error log. Constants::FOD_CLOUD_API_URL and DidClient::ENDPOINT_VARIABLE still hold FOD_CLOUD_API_URL and are deprecated, and Constants::CLOUD_ENDPOINT_VARIABLE and DidClient::CLOUD_ENDPOINT_VARIABLE hold the new name. Set _51DEGREES_CLOUD_ENDPOINT to the same value as the old name (#68, #79).
  13. An IP Intelligence dataFile setting is refused outside the command line. IpIntelligenceOnPremise built a new Engine from the Data File it names, so a web application that passed it loaded the Data File, several gigabytes for the Enterprise file, in every request. Under any server API other than cli it now throws an exception that says the setting is refused outside the command line and names the php.ini setting to use. Set FiftyOneDegreesIpIntelligenceEngine.data_file in php.ini, which builds the Engine once when PHP starts, and remove dataFile from the web application (IpIntelligenceOnPremise.php lines 178 to 181, #82).

What changes

Web integration and client-side caching

  • Utils::javascriptResponse() and Utils::jsonResponse() answer a client-side JavaScript or JSON endpoint with Cache-Control, Vary and an ETag. PHP has no web integration package, so call them from your own endpoints, as the web examples do (#48).
  • The ETag is a weak entity tag, W/"...", and If-None-Match is compared weakly, as RFC 9110 section 8.8.3 and section 13.1.2 ask. A match gets 304 for GET and HEAD, and 412 for any other method. A response to a request whose processing failed has Cache-Control: private, no-cache and no ETag. The same Evidence gives the same ETag in every process (#48).
  • PipelineBuilder gains the settings addSequenceElement, addJsonBundlerElement, addJavaScriptBuilderElement and addSetHeaderElement, each true by default (#45).
  • Evidence::setFromWebRequest() adds the web request as a read-only view under server.http-request (#49).

JavaScript Builder

  • The JavaScript Builder checks the callback URL before it writes it into the client script. The protocol must be http or https, and the host must be ASCII labels or an IPv6 address in brackets, with no user information, query, fragment or zone identifier, following RFC 3986. A URL that fails is left out of the script and logged (#31, #44).
  • The session id and the sequence in the script come from the Sequence Element. Without one the script has sequence 1 and an empty session id, whatever the request carried (#62).

Evidence

  • Evidence keys are compared ignoring letter case, so header.User-Agent and header.user-agent are one key (#63).

Cloud

  • The Cloud Request Engine can authenticate with a License Key, alone or with a Resource Key, through licenseKey. With a License Key alone the Properties wanted must be named with requestedProperties. Both keys are sent in the request body (#58).
  • Where one name is under more than one Evidence prefix, the Cloud Request Engine sends it once, taken from query, then header, cookie, server, fiftyone and location (#70).
  • A License Key appears in log entries and error messages only as its last 4 characters, with the rest as * (#52).
  • A Cloud Engine whose Resource Key lacks its product gives empty data and logs one warning, where IpIntelligenceCloud and GeoLocationCloud threw on every request (#54).
  • A cloud response that holds a 51Did is never stored in or served from a cache set with setCache (#55).

Device Detection and IP Intelligence

  • PHP reads each on-premise Property by its name. The Device Detection on-premise specification says a read SHOULD use the position of the name, and allows a read by name where a recorded measurement shows it is faster (specifications#141). In PHP a request that reads 15 Properties took 196,263 ns by name and 214,271 ns by position, as medians of 10 rounds of 3,000 requests with PHP 8.3 (run 43089315389). A Property that the Data File holds and the Engine was not built with has no value (#66, #72).
  • The Engines and the Pipeline build their Property metadata only when something asks for it. A Device Detection request that reads 15 Properties from a 109 MB Data File takes 196,263 ns, where it took 5,181,861 ns, as medians of 10 rounds in the same run (#72).

Translation

  • TranslationEngine can be built from a configuration file, with the specification's option names SourceElementDataKey, FixedLanguage and MissingTranslationBehavior. The named argument behavior: still works and is deprecated (#42).

C library

  • The C library moves from 51degrees-cxx 4.5.103 to 4.5.105. An IP Intelligence Engine built from a dataFile setting with no concurrency takes the C library's default, which is now the processor count, where it was 10. For 51D_GetHighEntropyValues and 51D_StructuredUserAgent the query value is used before the cookie value (#46).

Packages and platforms

  • fiftyone.pipeline.cloudrequestengine requires fiftyone.pipeline.core 4.5.207 or later (#52).
  • Both extensions compile the committed SWIG wrapper, and config.m4 no longer runs SWIG (#47).
  • The Device Detection and IP Intelligence package descriptions say what each package does, and name the products each is an alternative to (#16).
  • The repository has one LICENSE file at its root, the EUPL 1.2 text. The packages no longer carry a LICENSE file of their own, and each still declares EUPL-1.2 in composer.json (#73).

Examples and documentation

  • The on-premise examples run on the free Data Files, apart from the two that use Device Detection and IP Intelligence together. A Property that only the Enterprise Data File holds is shown as needing that file, with a link to ask for a copy (#35).
  • Every IP Intelligence on-premise example, console and web, uses the Engine the extension builds when PHP starts, from FiftyOneDegreesIpIntelligenceEngine.data_file in php.ini, as the Device Detection examples already did (#82).

Environment variables

Each name below has a new name that starts _51DEGREES_, which every shell can set. The Cloud Request Engine and the 51Did client read the cloud endpoint, the IP Intelligence package reads the folder of its translation files, and the examples and tests read the rest. Every old name in the table is still read in this release, but only when its new name is unset or empty. Using an old name is reported as deprecated, through the Pipeline's logger in the Cloud Request Engine and the IP Intelligence package, and in the PHP error log or on standard error in the 51Did client, the examples and the tests. Each constant that held an old name still holds it and is deprecated (#67, #68, #79, #80).

Old name, deprecated New name
FOD_CLOUD_API_URL, cloud_endpoint _51DEGREES_CLOUD_ENDPOINT
51DEGREES_DD_PATH, FIFTYONE_DATA_FILE _51DEGREES_DD_PATH
IPINTELLIGENCEDATAFILE _51DEGREES_IPI_PATH
resource_key, RESOURCE_KEY _51DEGREES_RESOURCE_KEY
LICENSE_KEY _51DEGREES_LICENSE_KEY
AcceptChPlatformKey, AcceptChHardwareKey, AcceptChBrowserKey, AcceptChNoneKey _51DEGREES_RESOURCE_KEY_ACCEPTCH_PLATFORM, _51DEGREES_RESOURCE_KEY_ACCEPTCH_HARDWARE, _51DEGREES_RESOURCE_KEY_ACCEPTCH_BROWSER, _51DEGREES_RESOURCE_KEY_ACCEPTCH_NONE
FIFTYONE_UA_CSV _51DEGREES_UA_CSV

Two old names have no new name. The Device Detection fork safety test reports FIFTYONE_WRAPPER as deprecated and loads nothing, because the extension now registers the classes that the wrapper file held (#47, #80). DEVICEDETECTIONDATAFILE is no longer read, because the IP Intelligence mixed web example read it into a variable that nothing used, so setting it never changed anything (#80).

Fixed

  • A php.ini that wrote a bare true for FiftyOneDegreesHashEngine.use_predictive_graph stopped PHP when it started (#39).
  • The Translation Engine's FlowError behavior raised a TypeError for a value with no translation (#42).
  • The JSON Builder wrote evidenceproperties as an object, which the client script cannot read, when the first Evidence Property was not delayed (#51).
  • A session id or sequence under a key in another letter case, such as Query.Session-ID, was written into the client script twice, so the script's snippets ran again on every page (#63).
  • The JavaScript Builder logged a refused callback URL with its user information, password included (#44).
  • Calling PipelineBuilder::build() twice added the Sequence Element, the JSON Builder, the JavaScript Builder and the Set Headers Element twice (#45).
  • After refreshData(), DeviceDetectionOnPremise kept the Property metadata of the Data File it was constructed with (#72).
  • The IP Intelligence wrapper freed collection settings that the Engine's configuration still owned, when one of four ConfigIpi getters was called (#46).

Issues this closes

None.

Build

Full build 43314252769 is green, with macOS. It ran from 21:50 to 22:10 UTC on 12 November 2026 on tmp/pr-matrix-76rel2. All 14 Setting 'PullRequestSha' lines, being Configure and the 13 Build and Test jobs, read ab719c7705aa4b7199d086b2cb0b12afdb855fae, which is this pull request's head and the head of rc, so every job built this tree and nothing else. The Build and Test jobs are Ubuntu PHP 7.4, 8.0, 8.1, 8.2, 8.3, 8.4 and 8.5, macOS PHP 8.1, 8.2, 8.3, 8.4 and 8.5, and Windows PHP 8.3. Every one of them succeeded, as did Configure, Compare Performance and Complete. Report Compare Performance was skipped because Compare Performance passed.

Compare Performance is within its history in all ten configurations it measures. The floor is the average of the last 10 results less the larger of two standard deviations and 10 per cent of the average.

Detections per second This build Average of the last 10 Standard deviation Floor
Ubuntu PHP 8.1 44,851 18,925 9,432 61
Ubuntu PHP 8.2 19,921 20,094 5,114 9,867
Ubuntu PHP 8.3 26,217 19,966 5,516 8,933
Ubuntu PHP 8.4 26,311 20,725 4,357 12,011
Ubuntu PHP 8.5 24,241 18,525 5,229 8,068
macOS PHP 8.1 31,847 16,826 6,300 4,225
macOS PHP 8.2 29,445 17,797 6,274 5,250
macOS PHP 8.3 15,641 20,000 4,809 10,382
macOS PHP 8.4 22,956 20,074 5,604 8,866
macOS PHP 8.5 21,622 16,160 3,788 8,584

Eight of the ten results are above their averages, so this head is not slower than its history. The two below are inside the spread of their history. macOS PHP 8.3 is 0.91 of a standard deviation under its average, and Ubuntu PHP 8.2 is 0.03 of a standard deviation under its average.

rc is final for this release. The last change was #82, merged at 21:50 UTC, which makes the IP Intelligence examples use the Engine built when PHP starts and refuses dataFile outside the command line. rc also holds the read by name (#72), which specifications#141 allows, and every old environment variable name read again as a deprecated name (#79, #80). Six drafts into rc stay open and are not in this release. #71 is superseded by #72 and is to be closed, not merged. #78 is a temporary red run for #72 and must not merge. #26, #37 and #77 wait for a later release. #81, which points AGENTS.md at the one list of environment variable names, is held until this release merges.

Only the patch number moves.

jwrosewell and others added 30 commits November 3, 2026 18:37
The README now opens with what the code does in the words developers search for, then the install command, try-it links and, where there are any, the repositories it was created from. It ends with a Find out more section, and the logo is a file in the repository. Everything else the README said is kept.
Links to the PHP repositories that are now read-only mirrors point at
their folders here, links to the C repositories go to 51degrees-cxx and
the .NET IP Intelligence link goes to 51degrees-dotnet. The test of the
51Did example page expects the page's new source address. One link text
named a repository that is not public and now names the folder.

The History table in README.md, the links to issues and pull requests in
the old repositories and the Packagist step, which needs the mirror's
address, are unchanged.
Co-authored-by: Automation51D <51DCI@51Degrees.com>
…ets (#38)

The 51Did specification says a reader ignores white space around the
base64 text, being any character with the Unicode White_Space property,
and refuses any other character outside the alphabet, white space inside
the text included. It also says a text uses one alphabet, so a text
holding a character from each pair is refused, and that check comes
before `-` and `_` are read as `+` and `/`.

FodId::toStandardBase64 trimmed with trim(), which takes five of the 25
white space characters and NUL as well, and mapped `-` and `_` onto `+`
and `/` with no check for a mix. PHP's strict base64_decode skips spaces,
tabs and line breaks inside the text, so tryFromBase64 and fromBase64
read a mixed text as if it were clean, and read some texts with white
space inside them, depending on how the white space changed the length.

toStandardBase64 now trims the 25 White_Space characters, then answers
null for a text with white space left inside it, with a character from
each alphabet, or that is not UTF-8. tryFromBase64 reports
ParseStatus::InvalidBase64 for such a text, and fromBase64 raises the
OwidException naming InvalidBase64. White space alone still reports
MissingInput.
…is on (#39)

* Read the on or off php.ini settings the way PHP does, so a bare true is on

The Hash extension read allow_unmatched, use_predictive_graph,
use_performance_graph and update_matched_useragent as on only when the
setting's text was exactly "true". PHP's ini parser turns a bare true in
php.ini, or in a -d option, into "1", so a setting written as
`FiftyOneDegreesHashEngine.use_performance_graph = true` was read as off.

The four settings now go through one helper, settingIsOn, which reads the
text with zend_ini_parse_bool, the function PHP uses for its own on or off
settings. "true", "yes" and "on" in any case, and text starting with a
number other than zero such as "1", are on, and anything else is off. A
setting left unset still leaves the C default in place.

CI builds the committed SWIG wrapper rather than generating one, so the
same change is made in hash-php.i and in src/php8/hash_wrap.cxx. The
readme's settings table says which values count as on, and gains the
allow_unmatched row it was missing.

* Test the on or off php.ini settings in a PHP process of their own

Nothing tested the reading of the on or off settings, so CI passed with
or without it. IniSettingsTests starts PHP with the settings on the
command line, as ExamplesWithoutSettingsTests starts the examples,
detects a Chrome User-Agent and reads the match method and the matched
User-Agent. With use_predictive_graph=off and a bare
use_performance_graph=true the method is PERFORMANCE, where PHP stopped
at start-up when true was read as off. update_matched_useragent=1
records the matched characters and off records none. The file is in the
Unit suite that CI runs.

The readme now says, as PHP reads them, that on, yes and true in any
case, and a number other than 0, are on, and that anything else is off.
…on does (#40)

* Hold the IP Intelligence data file in memory, as device detection does

PHP IP Intelligence built its engine with the Balanced profile by default,
and let php.ini or the performanceProfile setting pick LowMemory,
BalancedTemp or HighPerformance. Those profiles keep the data file open,
and a PHP process manager forks its workers after the extension has
loaded, so the workers share the file handles and their read positions.
Device detection avoids that by always loading the whole file
(device-detection-php-onpremise issue 38).

The extension now always builds its engine with MaxPerformance, and so
does IpIntelligenceOnPremise when it builds its own. A performance_profile
in php.ini, or a performanceProfile setting, other than MaxPerformance
raises an exception that names the setting, with the wording device
detection uses. The examples that chose LowMemory now use MaxPerformance,
the examples and the readme no longer pass a profile, and OnPremise.md
explains why the whole file is held in memory.

Constants::DEFAULT_PERFORMANCE_PROFILE and Constants::PERFORMANCE_PROFILES
give way to Constants::PERFORMANCE_PROFILE, and
Messages::INVALID_PERFORMANCE_PROFILE to Messages::PERFORMANCE_PROFILE_ERROR.
The committed SWIG wrapper is edited to match ipi-php.i.

* Keep the removed IP Intelligence names as deprecated aliases

Constants::DEFAULT_PERFORMANCE_PROFILE, Constants::PERFORMANCE_PROFILES and
Messages::INVALID_PERFORMANCE_PROFILE come back, so that code written
against them keeps working in a patch release. Each is marked deprecated,
names its replacement, and holds a value true of the engine as it now is.
DEFAULT_PERFORMANCE_PROFILE is MaxPerformance, PERFORMANCE_PROFILES lists
only MaxPerformance, and INVALID_PERFORMANCE_PROFILE equals
PERFORMANCE_PROFILE_ERROR. Nothing in the package uses them.

* Hold only the start-up IP Intelligence engine in memory

The engine the extension builds when PHP starts, from the php.ini
settings, is built before a process manager forks its workers, so it
stays forced to MaxPerformance. An engine IpIntelligenceOnPremise builds
from a dataFile setting is built in the process that uses it and opens
its own file handles, so it takes any profile again, with Balanced as
the default and the same validation as before.

IpIntelligenceOnPremise refuses a performance_profile in php.ini other
than MaxPerformance only when it takes up the start-up engine, as
DeviceDetectionOnPremise does, and the message says an engine built from
a dataFile setting may use any profile. Constants::DEFAULT_PERFORMANCE_PROFILE,
Constants::PERFORMANCE_PROFILES and Messages::INVALID_PERFORMANCE_PROFILE
hold their earlier values again and are no longer deprecated, and
Constants::STARTUP_PERFORMANCE_PROFILE names the start-up engine's
profile.

The console examples, which use the Enterprise data file when they find
one, use LowMemory, apart from the performance example, which measures
speed with MaxPerformance as the .NET one does. The web-mixed example's
per-request engine uses LowMemory again and loses an unused, hard-coded
device detection data file path, the web example says a per-request
engine may use LowMemory, and the readme names the Lite file as the data
repository does, 51Degrees-LiteV41.ipi.

* Drop the unused profile setting from the device detection console example

GettingStartedConsole passed 'performanceProfile' => 'LowMemory' to
DeviceDetectionOnPremise, whose constructor takes no settings, so the
option did nothing and the example always used the engine the extension
builds when PHP starts, which holds the data file in memory. The setting
goes, and the comment says where the engine comes from.

* Test the start-up IP Intelligence engine in a PHP process of its own

The tests of the start-up engine skip in CI, because CI's php.ini names
no data file, and the tests that run would pass on rc too, so putting
the profile switch back into ipi-php.i left CI green.

Two tests in the Integration suite now start PHP with the data_file
setting on the command line, as the device detection examples test does,
and build IpIntelligenceOnPremise from the start-up engine. It is taken
up with the php.ini profile unset or MaxPerformance, and refused with
PERFORMANCE_PROFILE_ERROR for LowMemory. Where /proc/self/fd exists, the
start-up engine holds no handle on the data file, and a LowMemory engine
built from a dataFile setting holds some, which shows the count works.
Elsewhere that test skips and says why. Both use the Lite data file.

The comments in ipi-php.i, its committed wrapper and
IpIntelligenceOnPremise state the reason once, briefly, and no longer
point at an issue.

* Keep the performance example on LowMemory, with MaxPerformance as an option

The performance example uses the Enterprise data file when it finds one,
which is over 6 GB, so it goes back to LowMemory by default, as the other
examples are. run() takes the profile as a fourth argument, and
performance-console.php reads it from an optional third argument, so
MaxPerformance measures the fastest profile where there is memory for
the whole file. The example prints the profile it ran with, and
OnPremise.md shows the command.
…lt (#41)

* Test that each element accepts only the Evidence it uses

* Make each element accept only the Evidence it uses

The JSON bundler, the Set Header element and the cloud aspect engines inherited the default filter that accepts every key, so a Pipeline kept every header in its Evidence and sent it to the cloud service. The JSON bundler now accepts only query.sequence, which it reads, and the other two accept nothing. The Cloud Request Engine fetches the cloud service's evidence keys the first time its filter is asked, so the Evidence the cloud service uses is kept when it is set, before processing. The test cloud service lists the keys the content tests set.

* Test that an element which declares no Evidence accepts none

* Accept no Evidence in an element that declares none, as the specification asks
…and write its Element Data (#45)

* Test the web integration element switches, order and Sequence Element Data

* Give each web integration element its own switch, put Sequence first and write its Element Data

* Name the data provider in the docblock as well, for PHPUnit versions that read annotations

* Use LF line endings in the new test file

* Test that the Sequence Element data is in the JSON sent to the browser

* Declare the Sequence Element's properties and stop the JSON Bundler skipping its data

* Update the tests that counted or compared the Sequence Element's data

* Test the Sequence Element with array, zero and sequence-only evidence

* Read the session id and sequence independently and ignore values that are not strings
…ification's option names (#42)

* Build the Translation Engine from a configuration file

PipelineBuilder::buildFromConfig passes an element's BuildParameters to
its constructor as one settings array, as every configured PHP element
takes them. TranslationEngine's constructor took positional arguments
only, so building it from a configuration file failed with a TypeError.

The constructor now also takes that settings array, with the Translation
Engine specification's option names, SourceElementDataKey (required),
FixedLanguage and MissingTranslationBehavior, or the same names in
camelCase, the style other PHP elements' settings use. The behavior is
matched ignoring case by MissingTranslationBehavior::fromName, and an
unknown one is refused. Sources and translations stay in code only, as
the specification says, and an engine built from settings uses the
element data key translation. The positional arguments, and their names
for named arguments, are unchanged.

Tests in pipeline/translation/tests cover each option, each spelling of
the behavior, an unknown behavior, a missing source, and the positional
and named-argument constructors, and the readme describes the
configuration file.

* Run the Translation Engine tests in CI

ci/packages.ps1 gave pipeline/translation Ci = $false and the package had
no ci folder, so CI never ran its tests. A build log reads "pipeline/
translation has no ci/run-unit-tests.ps1, skipping".

The flag is now true, which makes ci/build-project.ps1 and
ci/install-package.ps1 give the package the vendor folder that loads the
root autoloader, and the new pipeline/translation/ci/run-unit-tests.ps1
runs its tests through common-ci's php/run-unit-tests.ps1, as
pipeline/core's does. That script runs the suite named Unit, so the suite
in phpunit.xml is renamed from Translation, and the notes in
ci/packages.ps1, README.md and AGENTS.md say so.

The flag switches on nothing else. Set-RootAutoload is the only code that
reads it, mirror.yml reads Mirror and Assembled, and nothing in common-ci
reads this repository's package list.

* Record a missing translation as a flow error, and name the behavior argument as the specification does

With the FlowError behavior, a value missing from the map passed the
engine itself where FlowData::setError takes the error, so processing
raised a TypeError and the engine gave none of its properties. The error
is now recorded under the engine's data key, as every other element
records its errors, and the engine's properties are kept.

The sixth constructor argument is now missingTranslationBehavior, the
specification's name, which the configuration file already uses. A
seventh argument, behavior, is kept as a deprecated alias and used in its
place when given, so a caller passing behavior: as a named argument keeps
working. Positional calls are unchanged.

The docblock and the readme no longer credit the specification with
keeping sources and translations out of settings, as it now lets a
package read them from a configuration file. Not reading them is this
engine's choice. The readme says the country translation engines are
named with no BuildParameters, as their constructors take a folder.

New tests process an engine with FlowError and an untranslated value,
build the engine with each named form of the behavior argument, refuse a
setting value that is not text, and process an engine built from a
configuration, which gives no properties.
* Add a script and a check that generate the Device Detection wrapper

* Replace the hand-patched Device Detection wrapper with SWIG 4.5.1 output

* Give EvidenceDeviceDetection its own copies of the inherited map methods

* Regenerate the Device Detection wrapper with the evidence map methods

* Test the evidence map methods, build from the committed wrapper, and correct the dl() note
* Check the callback URL as the specification's six rules say

Refuse an IPv6 zone identifier, an '@', '?' or '#' anywhere in the host value, and a name ending in a number unless it is an IPv4 address in dotted decimal form. The logged URL shows *** in place of everything in the host before its last '@'.

* Test that a port which is present is not empty

* Refuse a port that is present but empty

A host such as https://example.com otherwise reads as the host https with an empty port. RFC 3986 section 3.2.3 allows an empty port, but section 6.2.3 treats it as equal to none, so the callback URL does not carry one.

* Test callback hosts that end in a line break
… assets'

Co-authored-by: Automation51D <51DCI@51Degrees.com>
…ix the README links (#36)

* Show the root logo in every README, add one contributing guide, and fix the README links

Seven READMEs showed an older 51Degrees wordmark from 51degrees.com rather
than the logo the root README shows, and the pipeline 51Did package README
had none. Each now shows the root logo as a Markdown image from
raw.githubusercontent.com, using .github/images/logo-160.png, a 160 pixel
copy of .github/images/logo.png, the size the root README shows it at.

The repository had no contributing guide of its own. One CONTRIBUTING.md
at the root now links the 51Degrees contributing guide.

The Developer Documentation link at the top of four READMEs went to the
main page of the product's generated API reference, which has no content,
and the cloud request engine's and pipeline core's went to the
documentation home page. They now go to the product's documentation.
Pipeline core's link had no space before its title, so GitHub showed it as
text, and now has one. The dependencies page that the on-premise Device
Detection README linked is gone, and the next sentence already links the
tested versions page, so the sentence is removed.

* Link each product's quick start from the READMEs

The documentation links went to section pages of the 51Degrees
documentation, such as _device_detection__index.html, which only list the
pages in the section. They now go to the page in each section that is most
helpful to a developer and the most recently kept up to date. Those are the
Device Detection, IP Intelligence and Reverse Geocoding quick starts, which
show how to choose between the cloud and on-premise and how to start in
each language, and Pipeline Basics for the Pipeline API, which has no quick
start. The campaign tags of each link are unchanged.

* Keep one documentation link where two pointed at the same page
…or (#16)

* Package text says what each package does in the words people search for

Developers find PHP packages by searching Packagist for words such as
user agent parser, device detection, and IP geolocation. The composer.json
description and keywords of the Device Detection and IP Intelligence
packages now use those words where they are true for the package.

- device-detection/cloud, which Packagist publishes as
  51degrees/fiftyone.devicedetection through the device-detection-php
  mirror, says it is a Device Detection and User-Agent parser that uses
  the 51Degrees cloud service. The keywords that named other companies'
  products are removed.
- device-detection/onpremise says it reads a data file on your server and
  gains the same keywords.
- ip-intelligence says what it finds about an IP address and gains the
  keywords ip-intelligence, ip-geolocation, ip-lookup, and ip-to-country.

Only description and keyword lines change. No version number changes.

* Package text names the products these packages are an alternative to

People who search Packagist for another product's name should find these
packages too. The Device Detection descriptions now end with "An
alternative to UAParser, DeviceAtlas and WURFL." and the IP Intelligence
description with "An alternative to MaxMind and IPinfo."

The keywords wurfl and deviceatlas are back on device-detection/cloud as
they were, and are added to device-detection/onpremise. The
ip-intelligence keywords gain maxmind and ipinfo.

Only description and keyword lines change. No version number changes.

* Name UAParser.js in the Device Detection descriptions

The Device Detection descriptions now end with "An alternative to
UAParser.js, DeviceAtlas, and WURFL." Naming UAParser.js keeps it apart
from the 51Degrees UAParser fork, and the comma before "and" follows the
company writing guide.

Only the two description lines change. No version number changes.
…vidence (#48)

* Test the cache headers of the client-side JavaScript and JSON responses

* Set the client-side cache headers, with a weak ETag of the supplied Evidence and If-None-Match read as RFC 9110 says

* Read the session id from a minified script, and the other process's ETag from its last line

* Set suppressProcessExceptions on the test Pipeline, which the builder does not pass on

* Run the send() test in its own process, as PHP refuses headers once output has started

* Install the pipeline packages from this checkout for the cloud example's Selenium tests

* Check the cloud example serves its client-side script before the Selenium tests start

* Test that an example route serves the script and the JSON through send()

* Test that Vary names every listed header and send() keeps default_mimetype

Vary should name every header in the Pipeline's Evidence key filter, not
only those the request carried, and leave out names that are not field
names. send() should stop PHP's default Content-Type on a 304 or a 412
without changing the default_mimetype setting. The example web tests also
check that the 304 carries no Content-Type.

* Name every header in the Evidence key filter in Vary

The web integration specification asks Vary to name all headers in the
Pipeline's Evidence key filter, so an element whose filter is a list now
names every header in it, whether or not the request carried it. A
filter that is a function cannot list its headers, so such an element
still names the request's headers it accepts. A name that is not an HTTP
field name is left out.

* Stop the default Content-Type without changing default_mimetype

send() set default_mimetype to an empty string for the rest of the
request. PHP reads that setting when it sends the headers, which for a
response with no content is at the end of the request, so it cannot be
restored any earlier. Setting an empty Content-Type and then removing it
stops PHP adding its default and changes no setting.
jwrosewell and others added 20 commits November 10, 2026 17:59
…ds a fodid (#55)

* Test that a Cloud Request Engine response holding a fodid is not cached

* Do not store or serve a cached Cloud Request Engine response that holds a fodid

* Import ElementData

* End the ArrayCache comment with a full stop
…e number (#50)

* Test that the JSON Bundler fails without a sequence number

* Fail the JSON Bundler, and set no JSON, when no Sequence Element has produced a sequence number

* Give the pipelines in the script tests a sequence number that leaves Evidence alone

* Test that the JSON Bundler reads the integer sequence number in Evidence

* Read the sequence number from Evidence, as an integer, and give the example a Sequence Element
…-request (#49)

* Test that the web integration adds server.http-request to Evidence

* Add the web request to Evidence as a read-only view under server.http-request

* Test that the request view's protocol is the request's own, not the Referer's

* Keep the request's own protocol in its view, name the unprefixed headers, and type get()
… or spaces (#59)

* Test that a sequence counts only as digits from 0 to 2147483646, with no sign or spaces

* Count a sequence only as digits from 0 to 2147483646, and render up to 2147483647

* Say exactly what getSequence renders, with and without a Sequence Element
Show only the last 4 characters of a license key in log entries and error messages
…the property list (#58)

* Test that log entries and error messages hold only the masked license key

* Show only the last 4 characters of a license key in log entries and error messages

* Correct the masked keys the list test expects

* Test a Cloud Request Engine on a license key alone, with each combination of credentials

* Authenticate a Cloud Request Engine on a license key alone, and send the property list

The engine takes licenseKey and requestedProperties settings. It builds
on a license key with a list, and sends the list as values whenever one
is given, beside a resource key too. It refuses at build only what the
cloud service refuses on every request, being no key at all and a
license key with no resource key or list.

The keys travel in the body of the data request, as resource and
license, and a license key is never put in a URL. The accessible
properties request posts the keys when there is a license key, and a
resource key on its own fetches them as before. The resource key still
names the data route.

When a list was sent, the first answer is compared with it, and the
names the answer left out are logged once as not covered by the keys.

* Test that the licence spelling of the key is masked in a URL

* Require the core release with LicenseKey, mask the licence spelling, and name the masking literals

* Test that a refusal naming the license key is masked, that a missing name is reported as not a property, and that a key is kept as given

* Mask the license key in the cloud's error text, report a missing name as not a property without a license key, keep a key as given, and list the settings

* Expect the configured resource key as resource in the body, and no Evidence value for it
* Test that the JSON Bundler adds a nullreason only for a Property with no value

* Add a nullreason only for a null value, and always as a string

* Test that a plain null has no entry and that a missing value has a reason that explains

* Write null and a nullreason only for a Property assigned no value, and no entry for a plain null

* Put the nullreason constant first in the class
* Test that delayexecution is written only for a Property with a value

* Write delayexecution only for a Property with a value
#60)

* Test that the JSON Bundler leaves out the Cloud Request Engine's data

* Leave the Cloud Request Engine's data out of the JSON Bundler's output
…65)

* Test that the cloud request carries every Evidence value but the reserved ones

A cloud-only pipeline is built whose single element is the Cloud Request
Engine, so the Engine's own evidence key filter is the only filter the
Evidence is offered to. That filter lists query.User-Agent alone, standing
for the keys the cloud service's evidencekeys answer gives.

The tests ask that an Evidence value the service does not list is still
sent, under the query, header and cookie prefixes, that an accepted value
is still sent beside them, that the reserved names resource, license,
licence and values are left out under any prefix and in any letter case,
and that the web request view under server.http-request is left out.

These tests fail before the change that follows, because Evidence::set
keeps a key only where some element in the pipeline accepts it, so a key
the service does not list never reaches the Engine.

* Send every Evidence value but the reserved ones in the cloud request

Evidence::set kept a key only where some element in the Pipeline accepted
it, and threw the rest away. On a cloud-only pipeline the Cloud Request
Engine is the only element, and its filter is the list of keys the cloud
service's evidencekeys answer gives, so a key the service does not list
never reached the Engine and was never sent.

Evidence::set now puts a key no element accepts in a second collection
instead of dropping it, and the Cloud Request Engine reads both when it
builds its request. A key is held in one collection or the other and never
in both, and a key accepted later replaces the earlier unaccepted copy,
which can happen because an Engine resolves its filter on first use.

getAll is unchanged and still answers with the accepted values alone, so
the element filtered view, the JavaScript served to the browser, the
client side cache key and get() by name all see exactly what they saw
before. The Engine's own evidence key filter is unchanged too, so a web
integration still adds only accepted values, as the web integration
specification requires.

The reserved names resource, license, licence and values were already left
out by addQueryData under any prefix and in any letter case, and the web
request view under server.http-request is held outside the Evidence
values, so neither is sent.

* Expect the cloud request to carry a header no element uses

AcceptedEvidenceTests asserted that a header no element uses is neither
kept nor sent, which is the behaviour the cloud request had before it
carried every Evidence value but the reserved names. The test now expects
that header in the request, and still expects get() to answer null for it,
because only the Cloud Request Engine reads the values no element accepts.

* Test that an accepted value is sent over an unaccepted one for one field

* Say why the unaccepted Evidence values come first in the cloud request

* Test that the web request key is never sent in any letter case

* Leave a value under the web request view's key out of the cloud request
…name fallbacks (#67)

* Rename the data file environment variable to _51DEGREES_DD_PATH

A POSIX shell cannot set a variable whose name starts with a digit, so
51DEGREES_DD_PATH could not be exported the way the examples described.
Every name now starts with an underscore, and the old name is not read
anywhere.

A new check reads every file git tracks and fails on a name that starts
with the digit, run by its own workflow on each pull request.

* Fail the environment variable check on a fallback name

Each variable has one name and nothing is read in its place, so the check
now also fails on a bare RESOURCE_KEY or LICENSE_KEY read or set as an
environment variable, and on a constant that keeps a legacy name to fall
back to. Only a read, a set or such a constant is matched, so a placeholder
key written between exclamation marks and a longer name ending in the same
words pass.

The 51Did example and live tests, and the Device Detection cloud examples
and tests, still read such names, so the check fails here until they go.

* Read each key from its one environment variable, with no fallback

The 51Did example and live tests read RESOURCE_KEY and LICENSE_KEY when the
_51DEGREES_ names were not set, and the Device Detection cloud examples and
tests read resource_key and the four AcceptCh names in the same way. Each
now reads its _51DEGREES_ name alone, and the CI scripts no longer set the
old names.

The readmes and AGENTS.md now tell a developer to set
_51DEGREES_RESOURCE_KEY, which is the only name the Device Detection cloud
examples read.

* Test the environment variable check, including a lower case name and a file whose name is not ASCII

* Match a name starting with the digit in any case, and read a path that is not ASCII
…ession id without one (#62)

* Test that without a Sequence Element the script has sequence 1 and no session id

* Render the Sequence Element's session id and sequence, and 1 and no session id without one

* Name the session id test after what it asserts

The test was called testSessionIdFromQueryIsRendered, which described the
behaviour before this branch, when the session id was read from the query
string. It now asserts that the session id comes from the Sequence Element
and is empty without one, so the old name contradicted it.

Only the name changes. The test body and its data provider are untouched,
and nothing else refers to the old name.
* Test that Evidence keys ignore letter case

* Compare Evidence keys without regard to letter case, in the store and in each filter

* Test that each reader finds an Evidence key in any letter case

Evidence keys ignore letter case, and getAll() hands back each key in the
letters it was first set with, so a reader that indexes the result with a
lower case literal misses the value. Three readers do that today and each
gets a test here, failing before the fix that follows.

The JavaScript builder leaves the session id and the sequence out of the
rendered parameters by an exact match, so a key such as Query.Session-ID
is sent as a parameter as well as in its own field.

The Translation Engine reads its three locale keys by an exact match, so a
header set as Header.Accept-Language resolves no locale and the values are
left untranslated.

IP Intelligence reads query.client-ip and server.client-ip by an exact
match, so a named key in other letters loses its precedence and an address
listed earlier is taken instead.

* Find an Evidence key in any letter case in each of the three readers

evidence.md line 10 says keys are matched without regard to letter case.
Evidence stores a value under the letters its key was first set with, and
getAll() hands those keys back, so each reader now allows for that.

The JavaScript builder lowers the key before matching it against the names
left out of the rendered parameters, so the session id and the sequence are
no longer sent twice.

The Translation Engine reads each of its keys through the Evidence, which
matches without regard to letter case already, rather than indexing what
getAll() returns.

IP Intelligence builds a lowered view of the keys it is given and matches
the two keys it reads by name against that, so those two keep their
precedence whatever letters they carry.

* Test that a key that left the store answers null, and that a key given twice counts once

* Answer null for a key that left the store, and read a key given twice once

* Test that two spellings of a key no element accepts are one key, and that the JavaScript Builder accepts only keys starting with query.

* Keep one spelling of each Evidence key across both collections, and accept only keys starting with query. in the JavaScript Builder
* Test that every on-premise read goes by the build-time property index

A property's index is its place in the list of names the engine was built
with, fixed at build, so the same index names the same property after any
reload from a data file whose property order differs or is a superset.

These tests cover both on-premise engines and fail against the current
code, which reads every value by name. Stubs stand in for the SWIG wrapped
objects, so the tests run without the native extension or a data file.

Device Detection gains a stub engine, result and value under tests/classes
and a SwigDataIndexTests file, registered in phpunit.xml. IP Intelligence's
existing stubs take the build-time name list, and its SwigDataTests gains
the index cases.

* Read every on-premise value by its build-time property index

Both on-premise engines read every value by name, so the native code looked
the name up on every read. A property's index is its place in the list of
names the engine was built with, fixed at build, so the same index names the
same property after any reload, and reading by index saves that lookup.

Each engine now maps its build-time names to their indexes once, from the
engine's own required property list rather than from the data set, whose
property order a reload can change. SwigData reads by that index.

The IP intelligence table is built by initPropertyIndexes, which runs at
construction only, so a refreshData call rebuilds the property metadata from
the new data file and leaves the index table alone.

An engine built with no property names keeps exactly today's behaviour and
reads by name, because native code then gives each property its own place in
the data set as its index, which a reload can move. That half follows once
the native change reaches its second release.

A property in the data set but outside a build-time list has no index, so it
carries the reason rather than a value.

* Test the build-time property index through the native extension, a name given twice, and a setting for one request

* Take the start-up engine's names from the setting PHP started with, and keep the first place of a name given twice

* Name the index test's process helper so it does not override PHPUnit's run

* Close the pipeline and FlowData the index test makes, as the resource check asks

---------

Co-authored-by: 51Degrees <noreply@51degrees.com>
…ber (#69)

* Test the exact JSON Bundler error text with no sequence number

* Give the specification's JSON Bundler error text with no sequence number

PHP gives the same text as every other language when no Sequence Element has produced a sequence number, with JSON Bundler in place of JSON Builder, as the JSON Builder page of the specification states.
* Move cxx to the 51degrees-cxx rc commit 865cee97 and regenerate the IP Intelligence wrapper

The cxx submodule moves from e866f6d3 (4.5.103) to 865cee97. The
IP Intelligence wrapper is regenerated with SWIG 4.5.1, the version that made
it. The move qualifies the MetaData result ownership settings, so four
CollectionConfig getters are no longer marked as returning a new object.

* Move cxx to the 51degrees-cxx rc commit b4f769ad

Takes the eight 51degrees-cxx pull requests merged into rc after 865cee97, among them the rename of src/common/Date.cpp to date_pp.cpp so it no longer clashes with date.c on a case-insensitive file system.

* Move cxx to the 51degrees-cxx rc commit cfad8978

Takes 51degrees-cxx #130, which writes the text of each IP Intelligence value with its weight in C, and #146, which gives a reason and a message when a property has no value in the matched results.

* Show the reason in the match metrics example when a property has no value

51degrees-cxx #146 gives a property with no value in the matched profile the message "None of the results contain a value for the requested property." where it gave a null message before. AspectPropertyValue throws that message when value is read, and returned null when the message was empty, so the example's listing of every property now stopped at the first such property. The example now prints the message, as the Node example does.

* Move cxx to the 51degrees-cxx release commit 7c4ac436

7c4ac436 is the second cxx release of 11 November, from 51Degrees/51degrees-cxx#150. It carries 51Degrees/51degrees-cxx#148, which keeps the first load's property order for an engine built with no names. No SWIG interface changes beyond a comment, so the committed wrappers stay as they are.

* Test that an engine built with no names reads by index

An engine built with no names takes the names native code gives after the first load as its list. Its table holds every one of them, a reload leaves the table and the names as they were, and a setting given for one request does not change the table. The Device Detection test also times a read by name, a read by index and a build of DeviceDetectionOnPremise on such an engine.

* Read by index on a Device Detection engine built with no names

DeviceDetectionOnPremise now builds its index table from getRequiredProperties whether or not the engine was built with names. Since 51Degrees/51degrees-cxx#148 an engine built with no names gives every property of the first data set, sorted by name, and a reload does not change them, so the index of each one is fixed in the same way as for an engine built with names. SwigData no longer falls back to a read by name for an empty table.

* Read by index on an IP Intelligence engine built with no names

IpIntelligenceOnPremise now builds its index table from getRequiredProperties whether or not the engine was built with names, for the start-up engine and for an engine built from settings. SwigData no longer falls back to a read by name for an empty table, and the startupSetting helper, used only to choose between the two, is removed.

* Time the index table on its own beside the build of the engine class

PHP builds DeviceDetectionOnPremise on every request, and an engine built with no names now builds its index table there, so the timed test also prints how long the table takes to build and how many names it holds.

* Leave an engine built with no names reading by name

This reverts 46e4ea4, c04b489, 8d855c0 and 62531ce. PHP builds DeviceDetectionOnPremise and IpIntelligenceOnPremise on every request, so an engine built with no names would build its index table on every request. In the full build on 62531ce the table of the Lite file's 15 names took 1,345 to 2,563 ns to build, where a read by index saved 24 to 70 ns against a read by name, so a request would have to make about 37 to 74 reads before the time saved covered the table. Reading by name stays the faster approach for such an engine until a decision is made.
…each name once in the key's own letter case (#70)

* Test that the Cloud Request Engine ranks Evidence prefixes in evidence.md's order

* Rank server, fiftyone and location Evidence in evidence.md's order, then other prefixes alphabetically

* Test that the Cloud Request Engine sends each Evidence name in the key's own letter case, once

* Send each Evidence name in the key's own letter case, once, and restore a missing space

* Put the precedence test helper after the teardown, private after protected
* Make the name check ignore case and fail on the names that remain, red

The fallback pattern kept its letter case only because the IP
Intelligence and location examples and tests read resource_key, which
differs from RESOURCE_KEY by case alone. That name goes in the next
commit, so the pattern now ignores case as the digit-initial one does.

It also fails on FOD_CLOUD_API_URL, IPINTELLIGENCEDATAFILE,
FIFTYONE_DATA_FILE and FIFTYONE_UA_CSV wherever they appear, since none
of them is ever anything but an environment variable, and on
CLOUD_ENDPOINT only where a variable is read, set or named in a
constant, because cloud_endpoint is a pipeline setting as well.

This commit is the check alone, so it fails over the repository as it
stands and names every place the next commit changes.

* Read every environment variable by its one _51DEGREES_ name

resource_key, read by the IP Intelligence, location and cloud request
engine examples and tests, becomes _51DEGREES_RESOURCE_KEY, which the
Device Detection cloud examples already read. That is what let the
check ignore letter case in the commit before this one.

The cloud endpoint was read under two names, FOD_CLOUD_API_URL by the
cloud request engine and the 51Did client, and cloud_endpoint by the
Device Detection cloud examples. Both become _51DEGREES_CLOUD_ENDPOINT,
the name the Rust library already reads. The cloud request engine's
constant is named after the variable, so it becomes
CLOUD_ENDPOINT_VARIABLE rather than carrying a name that starts with an
underscore.

IPINTELLIGENCEDATAFILE becomes _51DEGREES_IPI_PATH, FIFTYONE_DATA_FILE
becomes _51DEGREES_DD_PATH and FIFTYONE_UA_CSV becomes
_51DEGREES_UA_CSV.

The resource_key query string parameter and the cloud_endpoint pipeline
setting carry the same words and are left alone, because only the
environment variable moves.

* Stop the check naming the old variables itself

The four names that are never anything but an environment variable are
matched wherever they appear, so writing them out in the pattern and in
the comment above it made the check fail on its own source. Each is now
built from two pieces, as the digit-initial start already was, and the
comment says what they are rather than spelling them.

* Read the cloud request integration key from its own constant

CloudRequestEngineTestsBase::resourceKey did two jobs, being the fake
key the stubbed responses are keyed on and the name of the environment
variable the integration tests read a real key from. The two are now
separate constants, and the integration tests read
_51DEGREES_RESOURCE_KEY.

The full build caught this, because the check cannot see through a
constant whose own name does not say it holds a variable name.

* Merge rc, and see an old name braced as a PowerShell variable

rc has taken the move to cxx 7c4ac436 and five other pull requests since
this branch was built, and the merged tree has never been built, so it is
built again here. Nothing in what rc brought names an old variable.

The check also misses an old name written as ${env:NAME}, as Env:\NAME or
as a workflow ${{ env.NAME }}, because its prefix set wants the dollar and
the env token adjacent. The CI scripts here are PowerShell, where a name is
braced whenever it sits beside other text, so the check cannot see a form
the files it watches really use. The Go, Node and Java checks all match the
token on its own.

These cases fail until this one does the same.

* Match the env token on its own, so a braced old name is seen

The prefix set wanted the dollar sign and the env token next to each other,
so ${env:NAME}, Env:\NAME and a workflow ${{ env.NAME }} went past it.
Matching the token on its own, with a following backslash allowed, closes
all three and brings this check into line with the Go, Node and Java ones.
…submodule (#73)

* Keep one licence at the root, in the form 51degrees-cxx uses

The repository had no licence file at its root, and each of the nine
package folders held its own copy in one of three byte variants of the
header form, which GitHub cannot read as a licence.

LICENSE is now the plain EUPL v1.2 text, byte for byte the file in
51degrees-cxx, data, Go and Rust, and NOTICE holds the two additional
terms that the old files carried at their end and every source header
repeats. The package copies are removed, because Packagist reads the
licence from the license field of composer.json, which every package
declares as EUPL-1.2.

* Take the JavaScript template from a javascript-templates submodule

pipeline/core kept a copy of JavaScriptResource.mustache that the nightly
package update refreshed from an archive of the head of javascript-templates,
with no record of the commit the copy came from.

javascript-templates is now a submodule at the root, with the relative URL
the other language repositories use. The copy stays committed, because
Packagist serves GitHub's archive of a tag and an archive leaves a submodule
empty. The package update moves the submodule to the head of
javascript-templates and copies the templates from it, so the copy and its
commit change together, and the new JavaScript templates check fails when
the copy differs from the submodule.

* Assemble the 51Did package without a licence file of its own

assemble-package.sh copied pipeline/did/LICENSE into the published tree
and stopped when it was missing. The package copies of the licence are
gone, so the published tree carries none, as every other package now
does, and the OWID notice says the rest of the package is EUPL v1.2 as
composer.json declares and each file's header states.

* Give every PHP source file the standard 51Degrees header

Nine IP Intelligence examples and its php-cs-fixer configuration had a
header without the two additional terms, rewrite-composer-json.php had
none, and the Device Detection SWIG interface had the old address. Each
now carries the licenseText of the CopyrightUpdater settings in the tools
repository, with the year it already stated. The seven on-premise IP
Intelligence console examples are left for another open change that
edits them.

* Pin javascript-templates at c773ce22, the commit the copy came from

c773ce22 is the head of javascript-templates main, which the Java, Node
and Python repositories pin, and its JavaScriptResource.mustache is the
committed copy byte for byte.

* Keep the licence in LICENSE alone, with no NOTICE

The two additional terms are stated in the header of every source file,
which is where they apply, so NOTICE is removed and the README says where
the terms are, as in 51degrees-cxx.

* Give the on-premise IP Intelligence console examples the standard header

The seven console examples had a header without the two additional terms.
They now carry the licenseText of the CopyrightUpdater settings in the
tools repository, with the year they already stated, so every PHP source
file has the standard header.
…ry on-premise value by name (#72)

* Test that a request builds no table of every property

PHP builds DeviceDetectionOnPremise, IpIntelligenceOnPremise and the
pipeline on every request, while the native engine is built once, when PHP
starts. These tests say that a request that reads a few values builds
neither the table of every property in the data set nor the pipeline's
database of them, that the table is the same when it is asked for, and
that the database is built by the first search.

CoreTests::testUpdateProperties now searches once before it changes the
element's properties, because the database is taken at the first search
rather than when the pipeline is built, and it still checks that
updatePropertyList brings the change into the database.

* Build the pipeline's property database at the first search

PHP builds the pipeline on every request, and the Pipeline constructor
built the database of every element's property metadata that getWhere
searches, which with the TAC file took 1.6 ms of a 5.1 ms request. The
database is now built when it is first read, so a request that never
searches does not build it. Once built, updatePropertyDatabaseForFlowElement
updates it as before.

* Build Device Detection's table of every property only when asked for

PHP constructs DeviceDetectionOnPremise on every request, while the native
engine is built once, when PHP starts. Each request read the metadata of
every property in the data set (284 in the TAC file), which took 1.6 ms of
a 5.1 ms request.

The engine now builds the table of every property, and the index table,
when they are first read, and a read looks up only its own property in
native code, by the name the engine was built with. A read uses the table
instead once something has built it, and a name the engine was not built
with is looked up in the table, so every answer is the one the table gave.
After a data refresh the table is built again from the new data set.

* Build IP Intelligence's table of every property only when asked for

IpIntelligenceOnPremise is constructed on every request too, and read the
metadata of every available property each time. It now builds the table
and the index table when they are first read, and a read looks up only its
own property, as Device Detection does. When metadata is needed after the
engine is closed, it throws its engine closed message rather than failing.

* Test that every on-premise value is read by name

With the table builds gone, a request through a pipeline that reads by
name is faster than one that reads by index, so these tests say every
value accessor is handed the property's name. The stub tests are those of
the earlier by-name draft. The tests of the index table go, and a property
in the data set that the engine was not built with now carries the native
code's reason for having no value.

* Read every on-premise value by name

Timed over whole requests in one runner job, with the TAC file and the
table builds gone, a request that reads by name is faster than one that
reads by index at 5 and 15 names and level at 1. A read already finds
each property's spelling among the names the engine was built with, so it
hands that name to the native accessor, which looks it up itself, rather
than also finding its index in PHP.

The index table, getPropertyIndex, the not built message and the IP
Intelligence start-up setting reader, which only the index table used, go.
A property in the data set that the engine was not built with is read by
name, so it carries the native code's reason for having no value.

* Test the table fallback and asking a closed engine for a property

The Device Detection test of a property the engine was not built with
now checks that the table of every property was built, which only the
fallback in findProperty does there, and that the no value message is
not the one the missing property service writes.

The IP Intelligence close test asks whether a property exists before
and after the close.

* Ask a closed engine about a name nothing looked up

Run 43176251578 showed the test asking about the name it had already
asked about while the engine was open, which the engine keeps for the
rest of the request, so nothing reached the engine and the test could
not fail for the reason it was written for.

* Answer isset on a closed IP Intelligence engine

Looking a property up needs the engine, which close() releases, so
__isset now answers false rather than letting the engine closed
message out of isset, which cannot throw.

The comment in each findProperty says that the fallback reads the
table through __get, which the isset above it does not.

* Reach the closed engine when asking whether it has a property

Run 43185229593 failed testCloseReleasesResultsAndEngine on the head,
because the pipeline that test builds carries the JSON bundler and the
set header elements, and each reads every property, which builds the
table of every property. The table answers without the engine, so the
read never reached the closed engine and isset answered true.

The closed engine case moves to its own test, whose pipeline leaves
those two elements out, and which asserts the table is still unbuilt so
it cannot pass on the table alone. testCloseReleasesResultsAndEngine
goes back to what it was, covering the default pipeline.

* Let any failure but the engine closed one out of isset

The catch in SwigData::__isset took every exception and answered false,
so a fault reading the native metadata would have read as the engine
having no property of that name. Only the engine closed message now
answers false, and anything else reaches the caller.

Two stub tests pin both halves, and they need no data file, so they run
in every job.
* Test that the old cloud endpoint names are still there

The Cloud Request Engine and the 51Did client read the cloud endpoint
from _51DEGREES_CLOUD_ENDPOINT, and no longer read FOD_CLOUD_API_URL,
the name both published packages read before. Constants::FOD_CLOUD_API_URL
was renamed with nothing left under the old name, and
DidClient::ENDPOINT_VARIABLE kept its name and took the new value.

These tests say what a patch release has to keep. Only the old variable
set gives its endpoint, as does the new one set to nothing. Both set
gives the new one, and neither gives the default. Each old constant is
still there with the value "FOD_CLOUD_API_URL", marked deprecated and
naming the constant that replaces it. The old variable in use is
reported once, through the pipeline's logger by the engine and to the
PHP error log by the 51Did client, which has no logger.

The tests of the environment variable name check now want the old
endpoint name left alone, because the packages read it again.

The tests fail until the fix that follows.

* Keep the old cloud endpoint names for this release, deprecated

A patch release never removes or renames a public name, and that covers
the name of an environment variable a package reads. The Cloud Request
Engine and the 51Did client stopped reading FOD_CLOUD_API_URL, the
constant Constants::FOD_CLOUD_API_URL was renamed with nothing left
under the old name, and DidClient::ENDPOINT_VARIABLE kept its name and
took the new value.

Both now read _51DEGREES_CLOUD_ENDPOINT first and FOD_CLOUD_API_URL when
the new one is not set or is empty, so the new name wins when both are
set. The engine logs the old name in use once, as a warning through the
logger of the first pipeline it is added to. The 51Did package has no
logger, so the client writes it to the PHP error log once in the life
of the process.

Constants::FOD_CLOUD_API_URL is back with the value "FOD_CLOUD_API_URL",
marked @deprecated and naming Constants::CLOUD_ENDPOINT_VARIABLE.
DidClient::ENDPOINT_VARIABLE holds "FOD_CLOUD_API_URL" again, marked
@deprecated, and the new DidClient::CLOUD_ENDPOINT_VARIABLE holds the
new name.

The environment variable name check no longer fails on the old endpoint
name, because the packages read it again. It still fails on every other
old name.

* Say what the old variable name in the engine tests is for

The comment over the test constant said where the endpoint was read from
before, which is history. It now says what the constant holds and why
the tests write the name out.

* Say how often the deprecated variable is reported

The comments said the 51Did client writes the old variable to the PHP
error log once in the life of the process, and that the engine logs it
once. PHP frees a static at the end of every request, and a site builds
its pipeline in every request, so under a web server each is once in
each request. The comments now say that. No code changes.
…#80)

* Test that the old environment variable names are still read

Every environment variable name that a package, a test or an example
read in the last release is a public name. These tests set only the old
name and expect its value, set both and expect the new name's value, and
expect each constant that held an old name to keep its name and value,
marked deprecated. They fail until the old names are read again.

The tests of the name check expect the old names to be allowed in the
files that read them as deprecated names and nowhere else.

* Leave the cloud endpoint variable unset where the test expects none

An empty value under the new name gives an empty string today and is
not what the test is about, which is that neither name gives an
endpoint.

* Read every old environment variable name again, as a deprecated name

A patch release never removes or renames a public name, and every
environment variable name that a package, a test or an example reads is
a public name. The last release read resource_key, cloud_endpoint, the
four AcceptCh key names, 51DEGREES_DD_PATH, FIFTYONE_DATA_FILE,
FIFTYONE_UA_CSV, FIFTYONE_WRAPPER, IPINTELLIGENCEDATAFILE, RESOURCE_KEY
and LICENSE_KEY, and the IP Intelligence live cloud test read
FOD_CLOUD_API_URL. Each is read again, only when the name that replaced
it is not set or is empty, and its use is reported once. The new
_51DEGREES_ names stay the ones every message and comment gives.

Each constant that held an old name is back under its old name with its
old value, marked deprecated, and a new constant holds the new name.

The IP Intelligence package reads the old data file name when it looks
for the translation files, and writes its use to the PHP error log,
because nothing has a logger at that point. The examples, the test
helpers and the 51Did example server do the same through a small reader
of their own, because each package is published on its own. The fork
safety test writes to standard error. FIFTYONE_WRAPPER named a wrapper
file that the extension no longer needs, so it is reported and nothing
is loaded.

The name check fails on an old name anywhere but the files that read
the old names as deprecated names and the tests of those files, which
it lists. The old cloud endpoint name is checked in the same way again.

* Capture the PHP error log inside each test

PHPUnit 12 points the PHP error log at a file of its own while a test
method runs, so a capture set up before the method saw nothing and the
tests that count the deprecation notices failed with the fix in place.
The log is now pointed at the test's file around the code under test
and put back straight after.

* Say what the name check allows in its comment and its job name

The check's comment still explained a change of letter case by an old
name having gone, which is read again now, and the workflow's job name
said no tracked file names a fallback name, where the files that read
the old names as deprecated names do.

* Keep the deprecation notices out of the test output

Each test that reads a deprecated name on purpose now captures the PHP
error log around the read, so the notice is not printed among the test
results. The translation test makes the package's once-only report
possible again before it counts the lines, so it does not depend on
which test ran first.

* Test that the IP Intelligence package logs the old data file name

A package logs a deprecated name once through its own logger. The
translation engines are given the pipeline's logger when they are added
to a pipeline, so the tests expect one warning there, naming the old
and the new variable, however many pipelines are built, and none when
the new name is set or the translation folder is given to the engines.

* Log the old IP Intelligence data file name through the pipeline's logger

The package wrote the use of IPINTELLIGENCEDATAFILE to the PHP error
log. Its translation engines are given the pipeline's logger when they
are added to a pipeline, so the first of them added after the old name
was read now logs a warning there, once for each time PHP runs the
script. Nothing is written to the PHP error log.

* Test the wording of the IP Intelligence package's deprecation warning

The warning must say that a value was read from the old variable, in
the words the examples and test helpers use, and not that the data file
was read from it, because only the translation folder is looked for
beside the file the variable names.

* Say that a value was read from the old IP Intelligence variable

The package's warning said the data file was read from the variable.
Only the variable's value is read, to look for the translation folder
beside the file it names, so the warning now uses the wording the
examples and test helpers use. The comments that described the warning
say the same.

The fork safety test's comment on the wrapper variable says what the
code does and no longer what it did before.
* Test that the IP Intelligence examples use the engine built when PHP starts

Every on-premise example passes the dataFile setting or builds the native
engine itself, so under a web server the data file is opened and read in
every request. These tests fail until that is fixed.

- ExamplesUseStartupEngineTests fails while any example names dataFile or
  EngineIpiSwig.
- ExamplesWithoutSettingsTests runs each console example in its own PHP,
  started with FiftyOneDegreesIpIntelligenceEngine.data_file naming a free
  data file, as the Device Detection tests do, and checks an example
  started without that engine says how to start PHP with it.
- ExampleTests runs the examples in this PHP with the engine built when it
  started.
- IpIntelligenceOnPremiseTests checks an engine built from dataFile outside
  the command line logs one warning through the Pipeline's logger, and that
  nothing is logged on the command line or for the engine built when PHP
  starts. Its start-up helper now empties the php.ini data file when it
  builds from a dataFile setting, so that no engine is built when PHP
  starts there.

* IP Intelligence examples use the engine built when PHP starts

Every IP Intelligence on-premise example passed the dataFile setting or
built the native engine itself, so under a web server the data file was
opened and read in every request. A PHP on-premise engine is built once,
when PHP starts, from php.ini, which is what the Device Detection examples
already do.

- Every on-premise example, console and web, uses the engine built from
  FiftyOneDegreesIpIntelligenceEngine.data_file. The console scripts check
  that engine is there and, when it is not, print the command that starts
  PHP with a data file, found or downloaded as before, and stop. The
  web-mixed example no longer falls back to building an engine in the
  request.
- CI's php.ini for the package names the free Lite data file, as the
  Device Detection CI names its data file.
- The dataFile setting still works, and its doc comment says what it
  costs. The messages and the documentation no longer offer it as the way
  to run.

The warning the engine logs for the dataFile setting outside the command
line follows in the next commit, so that its tests can be seen failing
first.

* Close the element the performance and suspicious examples use

Both examples take the native engine from an IpIntelligenceOnPremise
element, which ResourceCleanupTests requires to be closed in a finally
block. The element is now kept and closed there. The engine it uses
belongs to the extension and stays loaded.

* Test that an IP Intelligence example runs with no data file in php.ini

The examples must run out of the box with the free data files. An example
started with no FiftyOneDegreesIpIntelligenceEngine.data_file in php.ini
now prints how to start PHP with one and stops, so this test, which runs
the getting started example with the setting emptied and the Lite file as
its argument, fails until the example runs again in a PHP started with
that file.

* Log once when an IP Intelligence engine is built from dataFile under a web server

The dataFile setting still builds a new engine from the data file every
time IpIntelligenceOnPremise is constructed, which under a web server is
every request. Outside the command line, the first engine built that way
now logs a warning through the logger of the first Pipeline it is added
to, saying so and naming the php.ini setting that builds the engine once
when PHP starts. It is logged once for each time PHP runs the script,
which under a web server is once in each request that builds such an
engine.

* Run an IP Intelligence example again in a PHP started with a data file

An on-premise example started with no engine built when PHP started now
runs again in a new PHP started with
FiftyOneDegreesIpIntelligenceEngine.data_file naming the data file it
finds, from its first argument, _51DEGREES_IPI_PATH, assets or the data
repository, the free file downloaded when there is none. So every example
still runs out of the box with the free data files, and the engine is
still built once, when that PHP starts. Where the system gives PHP's own
command line, its options, such as one loading the extension, are kept.

* Test that dataFile is refused outside the command line

Under a web server PHP builds IpIntelligenceOnPremise in every request,
so the dataFile setting would load the data file in every request. The
new test starts PHP's built-in web server, whose SAPI is cli-server, with
the php.ini data file naming the Lite file, and expects a request that
passes dataFile to be refused with Messages::DATA_FILE_SETTING_REFUSED,
and a request with no settings to use the engine built when PHP started.
A second test checks dataFile is still accepted on the command line.

These replace the tests of the warning, which the refusal replaces.

* Refuse dataFile outside the command line

Under a web server PHP builds IpIntelligenceOnPremise in every request, so
the dataFile setting would load the data file on every request. Under any
SAPI other than cli the setting is now refused with an exception saying
so, and naming FiftyOneDegreesIpIntelligenceEngine.data_file in php.ini,
which builds the engine once, when PHP starts. On the command line it
keeps working, and its doc comment says it is for command-line tools only.

This replaces the warning the engine logged for the setting, so the
warning's fields, onRegistration, isCommandLine and its message are gone.
@jwrosewell
jwrosewell marked this pull request as ready for review November 12, 2026 23:39
@jwrosewell
jwrosewell merged commit 59bb008 into main Nov 12, 2026
9 checks passed
@jwrosewell jwrosewell mentioned this pull request Nov 13, 2026
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants