Skip to content
Closed
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
8 changes: 3 additions & 5 deletions AGENTS.md
Original file line number Diff line number Diff line change
Expand Up @@ -109,11 +109,9 @@ Anything found follows When to Stop and Report — "the task didn't ask me to fi

During implementation, run new or changed test files immediately. After completing a coherent implementation slice, run the affected package or focused test suite.

At a meaningful checkpoint—such as before code review or after completing a substantial slice—run `composer fix` once. It runs `lint:fix`, both PHPStan configurations, the full parallel suite, the Testbench suite, and dogfood tests, so do not run those full checks separately at the same checkpoint.
Use checks that match the change. For isolated changes, run `composer lint:fix`, `composer analyse`, and the affected tests. Run a single affected test file with PHPUnit. Use ParaTest when the affected tests span multiple files. Only run `composer fix` when changes could affect code beyond the affected tests; it already runs formatting, analysis, and all test suites, so do not run those checks separately first.

After review fixes, run the relevant targeted tests. Repeat `composer fix` only when the changes warrant another full-repository check.

If `composer fix` fails, use targeted checks while correcting the issue. Afterwards, inspect the `fix` script in `composer.json` and run the failed check plus each remaining entry. Rerun an earlier check only if the correction could affect it.
If a check fails, use targeted checks while correcting the issue, then run the failed check and each remaining check. Rerun an earlier check only if the correction could affect it.

## Development Conventions

Expand Down Expand Up @@ -735,7 +733,7 @@ See the existing entries for database, Redis, Meilisearch, and Typesense as exam

The `tests/` directory is excluded from phpstan. Do not run phpstan on tests.

Full PHPStan runs through `composer fix` at checkpoints. During implementation, use targeted PHPStan only when investigating or validating a specific type issue.
Run full PHPStan checks with `composer analyse`. During implementation, use targeted PHPStan only when investigating or validating a specific type issue.

`phpstan.types.neon.dist` validates only the committed `types/` fixtures. Never pass source or test paths to it.

Expand Down
454 changes: 454 additions & 0 deletions docs/plans/2026-09-05-1421-lightweight-data-object.md

Large diffs are not rendered by default.

1 change: 1 addition & 0 deletions docs/todo.md
Original file line number Diff line number Diff line change
Expand Up @@ -27,6 +27,7 @@
- Convert untyped `$config->get()` calls across `src/` to the typed getters (`string()`, `integer()`, `float()`, `boolean()`, `array()`) without call-site defaults, for every key that isn't genuinely nullable. Defaults live in the merged config files — declare any key currently defaulted only at a call site in its package's config file as part of the conversion. Typed getters throw `InvalidArgumentException` naming the key on misconfiguration instead of letting a wrong type propagate silently, and give phpstan real return types. Bootstrap code that runs before config merging keeps its call-site defaults. Approved modernization per the Porting Packages policy in `AGENTS.md`; new code already follows the rule.
- Audit unmatched PHPStan inline ignores and global patterns with `reportUnmatchedIgnoredErrors` enabled — currently 196 unmatched inline ignores across 99 files plus 5 unmatched global patterns. Remove only suppressions that no longer match after tracing the underlying code; do not replace correct source with runtime branches or wider types merely to keep static analysis green. Decide as part of the work whether `phpstan.neon.dist` should then set `reportUnmatchedIgnoredErrors: true` permanently, since leaving it `false` lets the suppressions rot again.
- Add PHPStan Eloquent extensions that preserve `Eloquent\Builder<TModel>` for non-passthrough methods forwarded to `Query\Builder`, and expose model named scopes on Eloquent builders and relations. The query-builder mixin currently gives fluent calls the wrong builder type, while named scopes are treated as nonexistent methods; these gaps force scopes to split mutation from return and leave `HasDatabaseNotifications` with `method.notFound` suppressions. This will be the repository's first PHPStan extension, so use Larastan as prior art and wire the extensions into `phpstan.neon.dist` without maintaining duplicate query-method or scope lists in `@method` annotations.
- Audit typed input accessors across `InteractsWithData`, Support `DataObject`, and Hypervel Data. Define the accepted integer, float, and boolean forms for each public contract. Extract a neutral Support conversion primitive only if `InteractsWithData` and `DataObject` converge on exactly the same strict semantics, and decide separately whether Hypervel Data should adopt those semantics rather than changing its tested permissive casts as a side effect.

## Testing

Expand Down
5 changes: 3 additions & 2 deletions src/collections/src/functions.php
Original file line number Diff line number Diff line change
Expand Up @@ -53,9 +53,10 @@ function enum_try_from(string $enum, mixed $value): ?BackedEnum
return $enum::tryFrom($value);
}

// PHP's own coercion accepts the negative boundary because -2^63 is exactly
// representable as a float, and rejects the positive one and non-finite floats.
// PHP_INT_MIN is exactly representable as a float, while PHP_INT_MAX rounds up.
// Reject non-finite, fractional, and positive-boundary values before casting.
return is_float($value) && is_finite($value)
&& $value === floor($value)
&& $value >= (float) PHP_INT_MIN && $value < (float) PHP_INT_MAX
? $enum::tryFrom((int) $value)
: null;
Expand Down
80 changes: 80 additions & 0 deletions src/docs/data-objects.md
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,7 @@

- [Introduction](#introduction)
- [Choosing a Base Class](#choosing-a-base-class)
- [Lightweight Data Objects](#lightweight-data-objects)
- [Creating Data Objects](#creating-data-objects)
- [Creating Instances](#creating-instances)
- [Associating a Data Class](#associating-a-data-class)
Expand Down Expand Up @@ -50,6 +51,81 @@ The package provides three base classes:

Choose the base class that provides the behavior your object needs. Since `Dto` does not transform values, nested or collected DTOs remain objects when a surrounding `Data` object is transformed. Use `Data` or `Resource` when nested values should also be transformed.

For trusted internal values that only need typed construction and array or JSON output, consider a [lightweight data object](#lightweight-data-objects).

<a name="lightweight-data-objects"></a>
## Lightweight Data Objects

The `Hypervel\Support\DataObject` class provides a small mapper for internal message envelopes, per-item value objects, and other trusted values used in performance-sensitive code. It does not provide validation, property mapping, lazy properties, partials, resources, or persistence. Use `Data`, `Dto`, or `Resource` when you need those features.

To define a lightweight data object, extend `DataObject` and promote every constructor parameter as a public property:

```php
<?php

declare(strict_types=1);

namespace App\Messages;

use Hypervel\Support\CarbonImmutable;
use Hypervel\Support\DataObject;

final class MessageEnvelope extends DataObject
{
public function __construct(
public readonly string $id,
public readonly MessageType $type,
public readonly MessagePayload $payload,
public readonly CarbonImmutable $receivedAt,
public readonly ?string $traceId = null,
) {
}
}
```

Create the object using `from`:

```php
$message = MessageEnvelope::from([
'id' => 'msg_01',
'type' => 'created',
'payload' => ['name' => 'Taylor'],
'receivedAt' => '2026-09-05 12:34:56',
]);
```

In this example, `MessageType` is a string-backed enum and `MessagePayload` is another lightweight data object.

Constructor property names are the exact input and output keys. Unknown input keys are ignored, but names are not converted between camel case and snake case. Omitted parameters use their declared defaults, while omitted nullable parameters without a default receive `null`.

Common integer, float, boolean, and string representations are converted strictly. Backed enums, dates, and properties typed as a concrete `DataObject` are also converted. Invalid scalar values throw an `InvalidArgumentException` instead of being silently coerced. Use an application named factory when an external payload needs different names or custom conversion.

Integer-backed enums accept integral numeric values such as `1`, `"1.0"`, and `"1e0"`. Fractional values are rejected instead of being truncated to an enum case.

When a form request owns validation, you may declare a lightweight data object directly in its `casts` method. Use a wildcard to convert each member of a validated list:

```php
protected function casts(): array
{
return [
'contact' => Contact::class,
'contacts.*' => Contact::class,
];
}
```

The request returns a `Contact` from `validated('contact')` and an array of `Contact` objects from `validated('contacts')`. The DataObject does not run another validation step.

The `toArray` and `toJson` methods recursively normalize nested data objects, backed enums, dates, and `Arrayable` values. Public properties remain ordinary PHP properties and may be read or changed directly unless they are declared `readonly`.

An `array` property retains its input items as-is during construction. Convert a one-off list explicitly:

```php
$items = array_map(ItemData::from(...), $payload['items']);
```

Use Hypervel Data and `DataCollection` when a reusable typed collection needs validation, mapping, transformation controls, or response behavior.

<a name="creating-data-objects"></a>
## Creating Data Objects

Expand Down Expand Up @@ -475,6 +551,8 @@ $order->status === OrderStatus::Paid;
// true
```

Integer-backed enums accept integral numeric values, including decimal and exponent strings such as `"1.0"` and `"1e0"`. Fractional values are rejected instead of being truncated to an enum case.

<a name="casts-and-transformers"></a>
## Casts and Transformers

Expand Down Expand Up @@ -767,6 +845,8 @@ class StoreUserRequest extends FormRequest

The object is built through its normal `from()` pipeline. A present `null` remains `null`, and a missing input is not added to the validated result. Direct `Data`, `Dto`, and `Resource` request casts do not accept cast arguments; Eloquent-only options such as `default` and `encrypted` do not apply here.

A [lightweight data object](#lightweight-data-objects) may also be declared directly when the form request owns validation and you only need typed construction and array or JSON output.

Use `AsDataCollection::of()` when the input contains several objects. It returns a `DataCollection` by default and accepts the same explicit targets as `collect()`, including `'array'` and `Hypervel\Support\Collection::class`:

```php
Expand Down
20 changes: 19 additions & 1 deletion src/docs/validation.md
Original file line number Diff line number Diff line change
Expand Up @@ -777,7 +777,7 @@ protected function casts(): array
}
```

Integer-backed enums accept the numeric strings normally submitted by forms and JSON clients. Existing matching enum cases are preserved.
Integer-backed enums accept integral numeric values, including decimal and exponent strings such as `"1.0"` and `"1e0"`. Fractional values are rejected instead of being truncated to an enum case. Existing matching enum cases are preserved.

Use a wildcard cast for an ordinary enum array. Use `AsEnumCollection::of()` when you want a Support collection instead:

Expand Down Expand Up @@ -836,6 +836,22 @@ protected function casts(): array

Direct `Data`, `Dto`, and `Resource` request casts do not accept cast arguments. Eloquent-only options such as `default` and `encrypted` do not apply to validated request input.

You may also declare a `Hypervel\Support\DataObject` subclass when the form request owns validation and you only need lightweight typed construction:

```php
use App\Values\Contact;

protected function casts(): array
{
return [
'contact' => Contact::class,
'contacts.*' => Contact::class,
];
}
```

The wildcard applies the cast to each validated list member, so `validated('contacts')` returns an array of `Contact` objects. Lightweight data objects do not accept cast arguments or run another validation step. See the [data object documentation](/docs/{{version}}/data-objects#lightweight-data-objects) for guidance on choosing between these objects and Hypervel Data.

#### Custom Casts

A custom request caster implements the `CastsRequestInput` contract. Its `cast` method receives the concrete input key, its validated value, and the complete original validated input array:
Expand Down Expand Up @@ -2005,6 +2021,8 @@ $request->validate([
]);
```

For integer-backed enums, integral numeric values are valid while fractional values are rejected instead of being truncated to an enum case.

The `Enum` rule's `only` and `except` methods may be used to limit which enum cases should be considered valid:

```php
Expand Down
Loading