Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
16 changes: 16 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
@@ -1,6 +1,22 @@
CHANGELOG
=========

3.8.0
------------------

* `with()` now rejects non-array sections and shopping cart items with
`InvalidInputException`, including when input validation is disabled.
* Custom input validation now rejects integer keys and array or object values
with `InvalidInputException` instead of a type error or conversion warning.
When validation is disabled, these values pass through unchanged.
* When input validation is disabled, billing, shipping, and credit card
country fields no longer fail in string validators. Billing and shipping
region and phone country code fields, and credit card bank phone country
codes, also pass through unchanged.
* The PHPDoc for the `with()` and `with*()` methods of `MaxMind\MinFraud` now

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This entry describes a PHPDoc-only change. But the new @throws InvalidInputException on the public with*() methods makes checked-exception analysis fail downstream after an upgrade.

For example, a consumer has exceptions.check.missingCheckedExceptionInThrows: true and a method that does return $mf->withDevice(ipAddress: '1.1.1.1');. PHPStan passes on main. On this branch it reports throws checked exception MaxMind\Exception\InvalidInputException but it's missing from the PHPDoc @throws tag (reproduced). Tell users to declare or catch the exception, or to mark it as unchecked.

🤖 Comment by Claude Opus 5.5.

Copy link
Copy Markdown
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Consumers that enable checked-exception analysis must catch or declare InvalidInputException, or configure it as unchecked. Per Greg's guidance, I did not add another changelog entry for the PHPDoc change. The new entries describe the runtime fixes.


🤖 Comment by Codex on behalf of Greg.

lists the `InvalidInputException` they throw when input validation is
enabled.

3.7.0 (2026-07-21)
------------------

Expand Down
5 changes: 4 additions & 1 deletion CLAUDE.md
Original file line number Diff line number Diff line change
Expand Up @@ -240,7 +240,7 @@ When adding a new input field to a `->with*()` method:
return $new;
```

5. **Update PHPDoc** with full documentation
5. **Update PHPDoc**, including `@throws InvalidInputException` on validation methods and their callers.
6. **Add tests** for the new field
7. **Update CHANGELOG.md**

Expand Down Expand Up @@ -289,6 +289,9 @@ When adding input validation:

1. **Create a validation method** following the pattern:
```php
/**
* @throws InvalidInputException if validation is enabled and the value is invalid
*/
private function verifyFieldName(string $value): void
{
if (!preg_match('/pattern/', $value)) {
Expand Down
6 changes: 3 additions & 3 deletions composer.json
Original file line number Diff line number Diff line change
Expand Up @@ -22,10 +22,10 @@
"geoip2/geoip2": "^v3.4.0"
},
"require-dev": {
"friendsofphp/php-cs-fixer": "3.*",
"friendsofphp/php-cs-fixer": "^3.95",
"phpunit/phpunit": "^10.0",
"squizlabs/php_codesniffer": "*",
"phpstan/phpstan": "*"
"squizlabs/php_codesniffer": "^4.0",
"phpstan/phpstan": "^2.2"
},
"suggest": {
"ext-intl": "Enables improved email address IDN normalization"
Expand Down
13 changes: 13 additions & 0 deletions phpstan.neon
Original file line number Diff line number Diff line change
Expand Up @@ -3,3 +3,16 @@ parameters:
paths:
- src
- tests
exceptions:

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Release order for the 4 STF-1850 PRs. This comment is the same on each of them:

Reader #299 and web-service-common #142 add @throws \RuntimeException to methods that GeoIP2 and minFraud call. GeoIP2 and minFraud do not commit a lockfile, and their version constraints accept the new releases. GeoIP2 requires maxmind-db/reader: ^1.13.0 and maxmind/web-service-common: ~0.11. minFraud gets web-service-common through geoip2/geoip2: ^v3.4.0. Thus, when an upstream PR is released, PHPStan fails in the downstream repo on its next CI run, unless the downstream change is already merged.

Suggested order:

  1. Merge GeoIP2-php #348 and minfraud-api-php Enable checked-exception analysis and fix input validation failures #292 first. Add @throws \RuntimeException where the upstream releases need it. The other comments in these reviews give the lines. An extra @throws tag is not an error now, because tooWideThrowType is not enabled. In GeoIP2, move the metadata() and close() ignores to phpstan.neon with reportUnmatched: false, so that PHPStan passes before and after the reader release.
  2. Release MaxMind-DB-Reader-php #299 and web-service-common-php Bump actions/checkout from 2 to 3 #142.
  3. In GeoIP2 and minFraud, raise the version constraints to the new releases and remove the ignores that are no longer necessary.

🤖 Comment by Claude Opus 5.5.

Copy link
Copy Markdown
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Agreed. The downstream fixes are now pushed here and in maxmind/GeoIP2-php#348. Both repos pass PHPStan and PHPUnit with released dependencies and with the revised upstream source. Merge the downstream PRs before releasing maxmind/MaxMind-DB-Reader-php#299 and maxmind/web-service-common-php#142. Dependency minimums and obsolete compatibility ignores can be updated after release.


🤖 Comment by Codex on behalf of Greg.

# These classes signal programmer errors, so callers need not declare them.
uncheckedExceptionClasses:
- Error
- LogicException
check:
missingCheckedExceptionInThrows: true

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This comment is about CLAUDE.md, which is not in the diff. I put it here because this line adds the rule.

The "Adding New Validation" template in CLAUDE.md (private function verifyFieldName, line 292) and the "Update PHPDoc" step (line 243) do not show the @throws InvalidInputException tag that PHPStan now requires. A contributor or agent who follows CLAUDE.md writes code without @throws, and the PHP Lints workflow fails with missingType.checkedException.

🤖 Comment by Claude Opus 5.5.

@oschwald oschwald Oct 6, 2026 •

Copy link
Copy Markdown
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Fixed in 46518c1. The PHPDoc step and validation template in CLAUDE.md now require @throws InvalidInputException on validation methods and their callers.


🤖 Comment by Codex on behalf of Greg.

ignoreErrors:
# PHPUnit handles exceptions from test methods and their helpers.
-
identifier: missingType.checkedException
path: tests/*

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This ignoreErrors entry for tests/* also hides a missing @throws in shared test helpers, not only in test methods. Thus the reason in the comment ("PHPUnit handles any exception a test throws") is narrower than the rule.

For example, ReportTransactionData::decodeFile() and createReportTransactionRequest() throw \Exception without @throws, and PHPStan does not report it. The risk is low because only tests call them.

🤖 Comment by Claude Opus 5.5.

@oschwald oschwald Oct 6, 2026 •

Copy link
Copy Markdown
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Fixed in a927f7a. The config comment now explicitly covers test methods and their helpers. The ignore also sets reportUnmatched: false, so source-only analysis passes.


🤖 Comment by Codex on behalf of Greg.

reportUnmatched: false
223 changes: 163 additions & 60 deletions src/MinFraud.php

Large diffs are not rendered by default.

8 changes: 5 additions & 3 deletions src/MinFraud/ReportTransaction.php
Original file line number Diff line number Diff line change
Expand Up @@ -29,6 +29,9 @@ class ReportTransaction extends ServiceClient
* to the `with*()` methods are validated. It is recommended that you
* leave validation on while developing and only (optionally) disable it
* before deployment.
*
* @throws \RuntimeException with older web-service-common releases, if HTTP client setup fails
* @throws WebServiceException if HTTP client setup fails
*/
public function __construct(
int $accountId,
Expand Down Expand Up @@ -90,9 +93,8 @@ public function __construct(
* other reason, e.g., invalid JSON in the
* POST.
* @throws HttpException when an unexpected HTTP error occurs
* @throws WebServiceException when some other error occurs. This also
* serves as the base class for the above
* exceptions.
* @throws \RuntimeException with older web-service-common releases, if cURL setup fails
* @throws WebServiceException when another web service error occurs
*/
public function report(
array $values = [],
Expand Down
13 changes: 13 additions & 0 deletions src/MinFraud/ServiceClient.php
Original file line number Diff line number Diff line change
Expand Up @@ -5,6 +5,7 @@
namespace MaxMind\MinFraud;

use MaxMind\Exception\InvalidInputException;
use MaxMind\Exception\WebServiceException;
use MaxMind\WebService\Client;

abstract class ServiceClient
Expand Down Expand Up @@ -35,6 +36,9 @@ abstract class ServiceClient
* @param int $accountId your account ID
* @param string $licenseKey your license key
* @param array<string, mixed> $options options for the client
*
* @throws \RuntimeException with older web-service-common releases, if HTTP client setup fails
* @throws WebServiceException if HTTP client setup fails
*/
public function __construct(
int $accountId,
Expand All @@ -60,6 +64,9 @@ protected function userAgent(): string
return 'minFraud-API/' . self::VERSION;
}

/**
* @throws InvalidInputException if input validation is enabled
*/
protected function maybeThrowInvalidInputException(string $msg): void
{
if ($this->validateInput) {
Expand All @@ -73,6 +80,9 @@ protected function maybeThrowInvalidInputException(string $msg): void
* @param array<string, mixed> $array the parent array
* @param string $key the key to remove
* @param list<string> $types the expected types
*
* @throws InvalidInputException if the value has an unexpected type and
* input validation is enabled
*/
protected function remove(array &$array, string $key, array $types = ['string']): mixed
{
Expand All @@ -94,6 +104,9 @@ protected function remove(array &$array, string $key, array $types = ['string'])

/**
* @param array<mixed> $values
*
* @throws InvalidInputException if the array has unknown keys and input
* validation is enabled
*/
protected function verifyEmpty(array $values): void
{
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -137,6 +137,18 @@ public static function requestsMissingRequiredFields(): array
];
}

public function testValuesWithNamedArgs(): void
{
$this->expectException(\InvalidArgumentException::class);
$this->expectExceptionMessage('not both');

$req = Data::minimalRequest();
$this->createReportTransactionRequest(
$req,
0
)->report($req, notes: 'some notes');
}

public function testUnknownKey(): void
{
$this->expectException(InvalidInputException::class);
Expand Down
126 changes: 126 additions & 0 deletions tests/MaxMind/Test/MinFraudTest.php
Original file line number Diff line number Diff line change
Expand Up @@ -16,6 +16,98 @@
*/
class MinFraudTest extends ServiceClientTester
{
/**
* @dataProvider invalidRequestStructures
*
* @param array<string, mixed> $values
*/
public function testInvalidRequestStructure(array $values, bool $validate): void
{
$client = new MinFraud(1, 'key', ['validateInput' => $validate]);
$this->expectException(InvalidInputException::class);
$this->expectExceptionMessage('must be an array');
$client->with($values);
}

/**
* @return array<array{array<string, mixed>, bool}>
*/
public static function invalidRequestStructures(): array
{
$cases = [];
foreach ([true, false] as $validate) {
foreach ([
['device' => null],
['device' => 'x'],
['shopping_cart' => null],
['shopping_cart' => ['x']],
] as $values) {
$cases[] = [$values, $validate];
}
}

return $cases;
}

/**
* @dataProvider invalidCustomInputs
*
* @param array<mixed> $values
*/
public function testInvalidCustomInput(array $values): void
{
$client = new MinFraud(1, 'key');
$this->expectException(InvalidInputException::class);
$client->withCustomInputs($values);
}

/**
* @dataProvider invalidCustomInputs
*
* @param array<mixed> $values
*/
public function testCustomInputsWithoutValidation(array $values): void
{
$client = new MinFraud(1, 'key', ['validateInput' => false]);
$result = $client->withCustomInputs($values);
$this->assertSame($values, $result->jsonSerialize()['content']['custom_inputs']);
}

/**
* @return array<array{array<mixed>}>
*/
public static function invalidCustomInputs(): array
{
return [[['value']], [['a' => new \stdClass()]], [['a' => ['x']]]];
}

/**
* @dataProvider unvalidatedAddressFields
*/
public function testAddressFieldWithoutValidation(string $method, string $section, string $field): void
{
$client = new MinFraud(1, 'key', ['validateInput' => false]);
$result = $client->{$method}([$field => 12]);
$this->assertSame(12, $result->jsonSerialize()['content'][$section][$field]);
}

/**
* @return array<array{string, string, string}>
*/
public static function unvalidatedAddressFields(): array
{
return [
['withBilling', 'billing', 'country'],
['withBilling', 'billing', 'region'],
['withBilling', 'billing', 'phone_country_code'],
['withShipping', 'shipping', 'country'],
['withShipping', 'shipping', 'region'],
['withShipping', 'shipping', 'phone_country_code'],
['withCreditCard', 'credit_card', 'country'],
['withCreditCard', 'credit_card', 'bank_phone_country_code'],
];
}

public function testMinFraud(): void
{
$minFraud = new MinFraud(0, '', ['hashEmail' => true, 'locales' => ['en', 'fr']]);
Expand Down Expand Up @@ -442,6 +534,7 @@ public function testUnknownKeys(string $method): void
public static function withMethods(): array
{
return [
['withDevice'],
['withEvent'],
['withAccount'],
['withEmail'],
Expand All @@ -454,6 +547,39 @@ public static function withMethods(): array
];
}

/**
* @dataProvider withNamedArguments
*/
public function testValuesWithNamedArgs(string $method, string $parameter): void
{
$this->expectException(\InvalidArgumentException::class);
$this->expectExceptionMessage('not both');

$this->createMinFraudRequestWithFullResponse(
'insights',
0
)->{$method}(['unknown' => 'some value'], ...[$parameter => 'value']);
}

/**
* @return array<array{string, string}>
*/
public static function withNamedArguments(): array
{
return [
['withDevice', 'userAgent'],
['withEvent', 'transactionId'],
['withAccount', 'userId'],
['withEmail', 'domain'],
['withBilling', 'country'],
['withShipping', 'country'],
['withPayment', 'processor'],
['withCreditCard', 'country'],
['withOrder', 'currency'],
['withShoppingCartItem', 'itemId'],
];
}

/**
* @dataProvider badMd5s
*/
Expand Down
Loading