GithubHelp home page GithubHelp logo

spatie / laravel-data Goto Github PK

View Code? Open in Web Editor NEW
1.1K 11.0 194.0 2.51 MB

Powerful data objects for Laravel

Home Page: https://spatie.be/docs/laravel-data/

License: MIT License

PHP 99.99% JavaScript 0.01%
laravel php

laravel-data's Introduction

Powerful data objects for Laravel

Latest Version on Packagist Tests PHPStan Check & fix styling Total Downloads

This package enables the creation of rich data objects which can be used in various ways. Using this package you only need to describe your data once:

  • instead of a form request, you can use a data object
  • instead of an API transformer, you can use a data object
  • instead of manually writing a typescript definition, you can use... 🥁 a data object

A laravel-data specific object is just a regular PHP object that extends from Data:

use Spatie\LaravelData\Data;

class SongData extends Data
{
    public function __construct(
        public string $title,
        public string $artist,
    ) {
    }
}

By extending from Data you enable a lot of new functionality like:

  • Automatically transforming data objects into resources (like the Laravel API resources)
  • Transform only the requested parts of data objects with lazy properties
  • Automatically creating data objects from request data and validating them
  • Automatically resolve validation rules for properties within a data object
  • Make it possible to construct a data object from any type you want
  • Add support for automatically validating data objects when creating them
  • Generate TypeScript definitions from your data objects you can use on the frontend
  • Save data objects as properties of an Eloquent model
  • And a lot more ...

Why would you be using this package?

  • You can be sure that data is typed when it leaves your app and comes back again from the frontend which makes a lot less errors
  • You don't have to write the same properties three times (in a resource, in a data transfer object and in request validation)
  • You need to write a lot less of validation rules because they are obvious through PHP's type system
  • You get TypeScript versions of the data objects for free

Are you a visual learner?

In this talk, given at Laracon, you'll see an introduction to Laravel Data.

Support us

We invest a lot of resources into creating best in class open source packages. You can support us by buying one of our paid products.

We highly appreciate you sending us a postcard from your hometown, mentioning which of our package(s) you are using. You'll find our address on our contact page. We publish all received postcards on our virtual postcard wall.

Documentation

You will find full documentation on the dedicated documentation site.

Testing

composer test

Changelog

Please see CHANGELOG for more information on what has changed recently.

Contributing

Please see CONTRIBUTING for details.

Security Vulnerabilities

Please review our security policy on how to report security vulnerabilities.

Credits

License

The MIT License (MIT). Please see License File for more information.

laravel-data's People

Contributors

abenerd avatar adrianmrn avatar aidan-casey avatar alexmanase avatar alexrififi avatar alexvanderbist avatar andrey-helldar avatar bastien-phi avatar bennett-treptow avatar bentleyo avatar dbpolito avatar denjaland avatar dependabot[bot] avatar erikgaal avatar francoism90 avatar freekmurze avatar github-actions[bot] avatar hjanos1 avatar innocenzi avatar klaas058 avatar mohammad-alavi avatar morrislaptop avatar nielsvanpach avatar nir-an avatar ragulka avatar rubenvanassche avatar sebastiandedeyne avatar shuvroroy avatar sparclex avatar tofandel avatar

Stargazers

 avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar

Watchers

 avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar

laravel-data's Issues

How to set a default value for a property.

Discussed in #416

Originally posted by jonothedev April 16, 2023
How can we set a default value for a property? For example, if i were to create a data object, but set a value for a property from two other properties..

public string $first_name
public string $last_name

Then set a default value for username

$this->username = $this->first_name . '_' . $this->last_name;

Is this possible?

When making a POST request, I am unable to retrieve the payload correctly.

Versions

  • laravel/framework ^10.9.0
  • spatie/laravel-data ^3.5.0

Reproduction

  • POST /api/esports/cheer
    • {
      	"match_id": 1,
      	"team_id": 2
      }
  • Controller
     public function addCheerPointTeam(CheerPointTeamRequest $request)
     {
         return $this->serviceV2->addCheerPointTeam(
             $request->matchId,
             $request->teamId,
         );
     }
  • CheerPointTeamRequest
     #[MapInputName(SnakeCaseMapper::class)]
     class CheerPointTeamRequest extends Data
     {
         public function __construct(
             #[Required]
             public readonly int $matchId,
     
             #[Required]
             public readonly int $teamId,
         ) {
         }
     }

Output

Could not create App\Http\Controllers\CheerPointTeamRequest: the constructor requires 2 parameters, 0 given.

Prior to version 3.4.4, it was functioning correctly, but after updating to 3.5.0, the payloads are not being properly mapped in POST requests.

Throws cryptic error when parsing an array with an unknown key

✏️ Describe the bug
Using ExampleData::from with an array with an invalid (misspelled) key causes a cryptic error message.

  Spatie\LaravelData\Exceptions\CannotCreateData::Spatie\LaravelData\Exceptions\{closure}(): Argument #1 ($parameter) must be of type Spatie\LaravelData\Support\DataProperty, Spatie\LaravelData\Support\DataParameter given, called in vendor/laravel/framework/src/Illuminate/Collections/Traits/EnumeratesValues.php on line 780

  at vendor/spatie/laravel-data/src/Exceptions/CannotCreateData.php:35
     31▕         if ($parameters->isNotEmpty()) {
     32▕             $message .= " Parameters given: {$parameters->keys()->join(', ')}. Parameters missing: {$dataClass
     33▕                 ->constructorMethod
     34▕                 ->parameters
  ➜  35▕                 ->reject(fn (DataProperty $parameter) => $parameters->has($parameter->name))
     36▕                 ->map(fn (DataProperty $parameter) => $parameter->name)
     37▕                 ->join(', ')}";
     38▕         }
     39▕

↪️ To Reproduce

it('cannot validate nested data', function () {
    class ExampleData extends Data
    {
        public function __construct(
            public readonly int $cycle,
        ) {
        }
    }

    // Throws cryptic error
    ExampleData::from(['cycles' => 1]);

    // Correctly parses data
    ExampleData::from(['cycle' => 1]);
});

✅ Expected behavior
It should throw an error indicating the key/property is invalid (not found).

🖥️ Versions

Laravel: v9.52.4
Laravel Data: 3.6.0
PHP: 8.2.6

Could not create Data: the constructor requires 6 parameters, 5 given when boolean not passed

Hi there,

First, love Laravel Data. Thanks 🙏

I've ran into an issue while testing.
Here's my DTO with 6 required attributes:

class UserRegisterData extends Data
{
public function __construct(
        #[Max(50),
        Min(3),
        ]
        public string $firstname,
        public string $lastname,
        #[Unique('accounts'),
        Email('strict')]
        public string $email,
        #[Password(12, true, true, true, true)]
        public string $password,
        #[Size(2)]
        public string $country,
        public bool $optin_news,
    ) {
    }
}

Then I create my DTO doing

        $data = UserRegisterData::validateAndCreate($input);

If I pass only 5 attributes, like

 [
  "email" => "[email protected]"
  "firstname" => "John"
  "lastname" => "Doe"
  "password" => "&(§jjd==51254AAa"
  "optin_news" => false
]

It fails with a 422 error, which is expected behavior.

But If I don't pass optin_news:

[
  "firstname" => "John"
  "lastname" => "Doe"
  "email" => "[email protected]"
  "password" => "&(§jjd==51254AAa"
  "country" => "fr"
]

I got a 500:

Could not create `Domain\Shared\DataTransferObjects\UserRegisterData`: the constructor requires 6 parameters, 5 given.Parameters given: firstname, lastname, email, password, country.

I don't get why and how I should do to get a normal validation error when this arg is missing?

Thanks :)

TypeError when JsonNormalizer executed

Good day! Thanks for you awesome package and all you do!

Recently I've discovered an error when creating Data Object from JSON array (associative one).

My JSON is returned from external API, and looks pretty simple:

{
    "statusCode": "200",
    "status": "Success",
    "tnBasicList": {
        "tnBasicItem": [
            {
                "tn": "1234567890",
                "tnStatus": "In Service"
            },
            {
                "tn": "1234567890",
                "tnStatus": "In Service"
            },
            {
                "tn": "1234567890",
                "tnStatus": "In Service"
            },
            {
                "tn": "1234567890",
                "tnStatus": "In Service"
            }
        ]
    }
}

After JSON content retrieved, it's being decoded in assoc Array, like so:

[
    "statusCode" => "200",
    "status" => "Success",
    "tnBasicList" => [
        "tnBasicItem" => [
            [
                "tn" => "1234567890",
                "tnStatus" => "In Service"
            ],
            [
                "tn" => "1234567890",
                "tnStatus" => "In Service"
            ],
            [
                "tn" => "1234567890",
                "tnStatus" => "In Service"
            ],
            [
                "tn" => "1234567890",
                "tnStatus" => "In Service"
            ]
        ]
    ]
]

Data Objects are fairly simple:

 <?php

namespace My\Package\Namespace\Here;

use Spatie\LaravelData\Attributes\DataCollectionOf;
use Spatie\LaravelData\Data;
use Spatie\LaravelData\DataCollection;

class Response extends Data
{

    public function __construct(
        public int $statusCode,
        public string $status,
        #[DataCollectionOf(BasicList::class)]
        public DataCollection $tnBasicList,
    )
    {
    }
}

class BasicList extends Data
{
    public function __construct(
        #[DataCollectionOf(BasicItem::class)]
        public DataCollection $tnBasicItem
    ) 
    {
    }
}

class BasicItem extends Data
{
    public function __construct(
        public string $tn,
        public string $thStatus
    ) 
    {
    }
}

But when I do call

$content = 'JSON_STRING_HERE';
$data = json_decode($content, true);
$response = Response::from(...$data);

I'm getting TypeError, with traceback:

TypeError : Spatie\LaravelData\Normalizers\JsonNormalizer::normalize(): Return value must be of type ?array, int returned
 /Users/[...]/vendor/spatie/laravel-data/src/Normalizers/JsonNormalizer.php:16
 /Users/[...]/vendor/spatie/laravel-data/src/DataPipeline.php:81
 /Users/[...]/vendor/spatie/laravel-data/src/Resolvers/DataFromSomethingResolver.php:52
 /Users/[...]/vendor/spatie/laravel-data/src/Resolvers/DataFromSomethingResolver.php:54
 /Users/[...]/vendor/spatie/laravel-data/src/Concerns/BaseData.php:51
 /Users/[...]/tests/ResponseTest.php:6

which led me to the case when

  • "statusCode" value ("200" string) being occasionally threatened as JSON string,
  • then evaluated by json_decode()
  • and returned as int (as json_decode(...) result)

while return type from JsonNormalizer::normalize() is expected to be ?array

Is this a real bug here or I'm missing something?

Thanks!

Magic from() methods are not called when invoking `validateAndCreate`

<?php

namespace Tests\Unit;

use Spatie\LaravelData\Data;
use Tests\TestCase;

class Example3Test extends TestCase
{
    public function test_magic_from(): void
    {
        $songName = '[[ SONG NAME ]]';
        $song     = Song::from(['title' => $songName]);

        // Test begins here
        dump('first');
        $t = HitSong::from($song); // magic method `fromS` invoked as evident in output below
        dump($t->title_of_hit_song);
        self::assertEquals($songName, $t->title_of_hit_song);

        dump('second');
        $t = HitSong::validateAndCreate($song); // magic method not invoked, the title_of_hit_song is null
        dump($t->title_of_hit_song);
        self::assertEquals($songName, $t->title_of_hit_song);
    }
}

class Song extends Data
{
    public string $title;
}

class HitSong extends Data
{
    public ?string $title_of_hit_song;

    public static function fromS(Song $s): self
    {
        dump('custom method called');
        $o                    = new self();
        $o->title_of_hit_song = $s->title;

        return $o;
    }
}

output:

"first" // tests\Unit\Example3Test.php:16
"custom method called" // tests\Unit\Example3Test.php:39
"[[ SONG NAME ]]" // tests\Unit\Example3Test.php:18
"second" // tests\Unit\Example3Test.php:21
null // tests\Unit\Example3Test.php:23

Failed asserting that null matches expected '[[ SONG NAME ]]'.
Expected :[[ SONG NAME ]]
Actual   :null

Is there an alternative how to create an object like regular, but also have it validated?

502 Bad Gateway on 3.2.2

Upgrading to 3.2.2 results in a Bad Gateway on any request that uses Laravel Data, other requests are returned as expected. Downgrading to 3.2.1 resolves the issue.

How can I help troubleshoot this? I don't see anything in laravel.log related to this.

stopOnFirstFailure does not work

The documentation describes a method for stopping validation:

class SongData extends Data
{
    // ...

    public static function stopOnFirstFailure(): bool
    {
        return true;
    }
}

However, it doesn't work and I don't even see it mentioned in the library code.

withoutWrapping is not working when a global wrap is configured

In my data.php config I have 'wrap' => 'data', set and this is my DTO class:

class BundleData extends Data
{
    public function __construct(
        public readonly ?int $id,
        #[DataCollectionOf(RecipeData::class)]
        public readonly Optional|Lazy|DataCollection $recipes,
    ) {
    }

    public static function fromModel(Bundle $bundle): self
    {
        return self::from(
            [
                ...$bundle->toArray(),
                Lazy::whenLoaded('recipes', $bundle, fn () => RecipeData::collection($bundle->recipes)->withoutWrapping()),
            ]
        );
    }
}

The recipes are still wrapped in data in the json output in my API routes.

Validation applied on the request

Hi guys,

Doing some refactoring on our code base, using Laravel Data

Consider the following data object for creating a password reset request.
On our passwordreset we have a type; when a user creates a password reset from the controller, we want to have the type by default set to the default password reset type.

class CreatePasswordResetData extends Data
{
    public function __construct(
        public readonly string $email,
        #[WithCast(EnumCast::class)]
        public readonly PasswordResetType $type,
    ) {
    }

    public static function rules()
    {
        return [
            'email' => 'required|email',
            'type' => 'required',
        ];
    }

    public static function fromRequest(Request $request): static
    {
        return new self(
            email: $request->input('email'),
            type: PasswordResetType::default()
        );
    }
}

So I created the fromRequest() function, which I believe would be used to create the Data object from a request.
Using injection in the controller, I then do:

public function __invoke(CreatePasswordResetData $data, CreatePasswordResetAction $action) : void
    {
         $action($data);
    }

So in summary:

  • we have a Data object that we want to use in the controller, but also in other parts of the code
  • a valid Data object should always have the email address and the type, hence we validate both of them in the rules()
  • however when creating by the frontend using the controller, the type is not available, so we are using the fromRequest() method to add the default value for the type

It was my understanding that when injected into the controller __invoke method, the laravel-data would create the Data object using the fromRequest() method, and apply validation rules on the result however, it seems that the validation rules are being applied onto the request (without the default value for type)... Is this expected behaviour, or is this actually a defect?

If it is expected behaviour, how can I then reuse these kinds of Data objects without having to duplicate them into two different models and still ensuring my Data object has all the properties that I expect?

Magic Method ::from does not work with nested collections

I'm using AWS to call the Textract API, which returns a bunch of nested data

class TextractExpenseAnalysisResponse extends Data
{
    public function __construct(
        #[DataCollectionOf(ExpenseDocument::class)]
        DataCollection $ExpenseDocuments,
        string $JobStatus,
    ) {}
}
class ExpenseDocument extends Data
{
    public function __construct(
        int $ExpenseIndex,
        Collection $SummaryFields,
        Collection $LineItemGroups,
        Collection $Blocks
    ) {}
}

However, when I try to call a TextractExpenseAnalysisResponse::from($json), I get the following:

App\Domains\Claims\DataObjects\TextractExpenseAnalysisResponse::__construct(): Argument #1 ($ExpenseDocuments) must be of type Spatie\LaravelData\DataCollection, array given, called in ./vendor/spatie/laravel-data/src/Resolvers/DataFromArrayResolver.php on line 60

It works when I don't add the DataCollection of ExpenseDocuments, but as soon as I do, it breaks there.

I've tried everything from making the inner-elements into collections as well, but I keep getting this problem. Would love to debug the problem further, as I want to use this library for typing all responses from an API.

404 instead 422

Data class

class StoredMedia extends Data
{
    public function __construct(
        #[Validation\Required, Validation\StringType]
        public string $project,
    ) {
        //
    }
}

API Controller method

public function store(StoredMedia $data)
{
    dd($data->toArray());
}

Request with post method return 404 Not Found instead 422 status.

If I remove string with Validation rules and replace public string $project with public ?string $project, all is ok.

  • Laravel Framework 10.4.1
  • spatie/laravel-data 3.2

Unexpected behaviour when using `fromRequest` magic method

Hi there, let me start by saying thanks for all the great php libraries from Spatie!

I ran into something unexpected when trying to validate requests using laravel-data. In particular when trying to set defaults for non-nullable properties in the static fromRequest method.

I've added a test case to reproduce the behaviour to a fork.

It appears that perhaps validation is run on the Laravel request before the data object is constructed? It may be that this is the intended behaviour and I just missed that in the documentation? I guess my expectation looking at the api of the library was that the validation would be run against the constructed data object.

Memory Leak / Infinite Loop when trying to validate or getValidationRules

Just hit a bug where Laravel throws runs out of memory when trying to do something like User::validate($array) or even User::getValidationRules().

Symfony\Component\ErrorHandler\Error\FatalError 

  Allowed memory size of 2147483648 bytes exhausted (tried to allocate 57344 bytes)

  at vendor/spatie/laravel-data/src/Resolvers/DataPropertyValidationRulesResolver.php:154

I found that if a DTO has relations configured that have circular dependencies, it runs out of memory:

e.g.
Models:
User
Recipe

Relations:
A user has many recipes
A recipe belongs to a user

When I comment out the following lines on the User DTO it works:

public function __construct(
      #[DataCollectionOf(RecipeData::class)]
      public readonly Optional|Lazy|DataCollection $recipes,
){}

Spatie\LaravelData\Support\Validation\ValidationContext::__construct(): Argument #1 ($payload) must be of type ?array, string given, called in /var/www/application/vendor/spatie/laravel-data/src/Resolvers/DataValidationRulesResolver.php on line 204

laravel-data version 3 is used.

How to reproduce.

Main DTO file

<?php
declare(strict_types=1);

namespace App\DTO;

use Spatie\LaravelData\Data;

class CreationRequest extends Data
{
    public string $name;

    public Address $address;

    public static function rules(): array
    {
        return [
            'name' => 'required|string',
            'address' => 'required|array:street,postcode,city',
        ];
    }
}

Nested Address DTO

<?php
declare(strict_types=1);

namespace App\DTO;

use Spatie\LaravelData\Data;

class Address extends Data
{
    public string $street;

    public string $postcode;

    public string $city;
}

The action in the controller looks like this

    public function store(CreationRequest $requestDTO): Response
    {
        .....
    }

If we send a request with data like this

        [
            'name' => 'Test name ',
            'address' => 'Some address here'
        ];

then there will be an error Spatie\LaravelData\Support\Validation\ValidationContext::__construct(): Argument #1 ($payload) must be of type ?array, string given, called in /var/www/application/vendor/spatie/laravel-data/src/Resolvers/DataValidationRulesResolver.php on line 204.

Yes, an address field in the request is wrong, it shouldn't be a string and should be an array, but if a client sent a string to a server, there is definitely should not be an error.

In the laravel-data version 2 a rule "'address' => 'required|array:street,postcode,city'," applied and there would be a validation error like "The address must be an array"

Shortly, it is not possible to work with nested objects, because if there is not an array for this object property in the request, then there will be a crash.

Request is validated before casts, impossible to submit '1,2' as float due to comma.

↪️ To Reproduce
There are quite a lot of different code files needed to reproduce this but based on my previous experience I feel like this issue might be put to the bin a.k.a. converted to discussion. However if you guys need it I will give you a reproduceable constellation: it consists of a controller which accepts a spatie data object as request object, route, and custom cast.

Here's what should give you enough info:

class MyCustomRequest extends Data
{
    #[WithCast(CastNumericInputToFloat::class)] // takes string input and returns extracted `float` value
    public float $weight;
}


// test:

    public function test_stepOneStore(): void
    {
        $this->post(route('stepOneStore'), [
            'weight'           => '123,45', // users WILL submit comma-separated decimals here!
        ])->assertOk(); // Expected response status code [200] but received 302. "Weight must be numeric"
    }

✅ Expected behavior

The validated value should be post-cast, i.e. the float that was returned from CastNumericInputToFloat not the submitted string.

🖥️ Versions

All latest stable.

Wrapping Collections Doesn't Work As Expected

✏️ Describe the bug
When doing as the manual describes to wrap a collection (DataObject::collection()->wrap('foo')), the result JSON should be an object with a single key (foo) that is an array of objects representing each DataObject in the collection.

↪️ To Reproduce
Provide us a pest test like this one which shows the problem:

// Added to tests/DataCollectionTest.php
it('wraps data collection', function() {
    $collection = SimpleData::collection(['A', 'B'])->wrap('test');

    expect($collection->toJson())
        ->toEqual('{"test": [{"string":"A"},{"string":"B"}]}');

    expect(json_encode($collection))
        ->toEqual('{"test": [{"string":"A"},{"string":"B"}]}');
});

it('wraps data collection of data objects', function() {
    $collection = SimpleData::collection([
        SimpleData::from('A'),
        SimpleData::from('B'),
    ])->wrap('test');

    expect($collection->toJson())
        ->toEqual('{"test": [{"string":"A"},{"string":"B"}]}');

    expect(json_encode($collection))
        ->toEqual('{"test": [{"string":"A"},{"string":"B"}]}');
});

Outputs:

  • Tests\DataCollectionTest > it wraps data collection of data objects
  Failed asserting that two strings are equal.

  at tests/DataCollectionTest.php:550
    546▕         SimpleData::from('B'),
    547▕     ])->wrap('test');
    548▕
    549▕     expect($collection->toJson())
  ➜ 550▕         ->toEqual('{"test": [{"string":"A"},{"string":"B"}]}');
    551▕
    552▕     expect(json_encode($collection))
    553▕         ->toEqual('{"test": [{"string":"A"},{"string":"B"}]}');
    554▕ });
  --- Expected
  +++ Actual
  @@ @@
  -'{"test": [{"string":"A"},{"string":"B"}]}'
  +'[{"string":"A"},{"string":"B"}]'
  • Tests\DataCollectionTest > it wraps data collection
  Failed asserting that two strings are equal.

  at tests/DataCollectionTest.php:537
    533▕ it('wraps data collection', function() {
    534▕     $collection = SimpleData::collection(['A', 'B'])->wrap('test');
    535▕
    536▕     expect($collection->toJson())
  ➜ 537▕         ->toEqual('{"test": [{"string":"A"},{"string":"B"}]}');
    538▕
    539▕     expect(json_encode($collection))
    540▕         ->toEqual('{"test": [{"string":"A"},{"string":"B"}]}');
    541▕ });
  --- Expected
  +++ Actual
  @@ @@
  -'{"test": [{"string":"A"},{"string":"B"}]}'
  +'[{"string":"A"},{"string":"B"}]'

✅ Expected behavior

The collection values should be wrapped in the specified wrap() key.

{
  "test": [
    {
      "string": "A"
    },
    {
      "string": "B"
    }
  ]
}

🖥️ Versions

Laravel: 10.12.0
Laravel Data: 3.5.1
PHP: 8.1.18

DataCollection throws CannotCreateData instead of ValidationException

✏️ Describe the bug

When we create a DataCollection for ex:SomeData::collection($arrayPayload) the exception being thrown when a property is missing is not a ValidationException and instead we get a CannotCreateData. In this scenario we are not using the SomeData::validateAndCreate($payload) since we need to create a collection, and it seems we are not actually processing the data mappings using the $validator.

↪️ To Reproduce

class SongData extends Data
{
    public function __construct(
        #[Rule(['required','string'])]
        public string $title,
        #[Rule(['required','string'])]
        public string $artist,
    ) {
    }
}

SongData::collection([
    ['title' => 'Some title'],
    ['title' => 'Some other title', 'artist' => 'Some artist'],
]);

This will throw a CannotCreateData exception and provide a different message, than it would with the ValidationException which we can overwrite the validation message for.

✅ Expected behavior
Allow DataCollections when fail to create to throw the ValidationException with the custom message provided.

🖥️ Versions

Laravel: 9.52
Laravel Data: v3
PHP: 8.1

Nested objects with empty request payloads / defaults for all properties

This might be a bit of an edge-case, but I'm running into a problem with nested data objects when the request has an empty payload. In my situation this is happening because I have a data object with defaults for every property.

For example, say you have nested data objects like this:

class ParentData extends Data
{
    public function __construct(
        public bool $example_bool,
        public ChildData $child,
    )
    {
    }
}

class ChildData extends Data
{
    public function __construct(
        public ?string $example_string = 'example',
        public ?int $example_int = 123,
    )
    {
    }
}

When using from there is no problem creating the object from a payload like this:

public function test_from()
{
    $data = ParentData::from([
        'example_bool' => true,
        'child' => [],
    ]);

    $this->assertInstanceOf(ParentData::class, $data);
    $this->assertInstanceOf(ChildData::class, $data->child);
}

That passes. However, when the data object is created from a request with the same payload it fails:

public function test_resolve_from_request()
{
    request()->merge([
        'example_bool' => true,
        'child' => [],
    ]);

    $data = app(ParentData::class);

    $this->assertInstanceOf(ParentData::class, $data);
    $this->assertInstanceOf(ChildData::class, $data->child);
}

I think it would be nice if we could make it a bit more consistent.

In laravel-data versions before 3.5.0 it failed because child would automatically have a required rule added. I was able to bypass this by changing the validation rule from required on nested data objects to present. This is because I wanted empty arrays to be allowed (required doesn't allow an empty array to pass validation). I think present might be a good choice in this situation as it would allows objects with defaults for every property.

I did this by adding my own rule inferrer that swapped the required rule with present:

class DataPresentInsteadOfRequiredRuleInferrer implements RuleInferrer
{
    public function handle(
        DataProperty $property,
        PropertyRules $rules,
        ValidationContext $context,
    ): PropertyRules
    {
        if (! $property->type->isDataCollectable && ! $property->type->isDataObject) {
            return $rules;
        }

        if (! $rules->hasType(Required::class)) {
            return $rules;
        }

        return $rules->removeType(Required::class)
            ->add(new Present());
    }
}

However, in version 3.5.0 this no longer works. Instead the error is that child isn't passed into the constructor of the parent data object.

I think this is happening because laravel doesn't treat an empty array as validated - even if it had validation rules run on it. E.g.

$validator = Validator::make(
    [
        'settings' => [],
    ],
    [
        'settings' => ['array', 'present'],
        'settings.primary_colour' => ['nullable', 'string'],
    ]
);

dump($validator->validate());// []

dump($validator->errors());// Illuminate\Support\MessageBag { messages: [], format: ":message" }

dump($validator->validated());// []

That exaple passes validation, but settings doesn't appear in validated even though it had the array and present rules run on it.

Is there a way we can support empty arrays as payloads for data objects with defaults?

Error when using resolve() with laravel-data classes

I am using your library to organize my data, but when I tried to create a data object using the resolve() function provided by laravel and passing the parameters needed to instantiate the data object did not work and was always using the parameters sent in the request.

Here is an example of what I am doing:

class ContactData extends Data
{
    public function __construct(
        public string $name,
        public string $email,
    )
    {}
}

resolve(ContactData::class, [
    'name' => 'Bob',
    'email' => '[email protected]',
]);

After some research, I could find the problem.
The problem is in the LaravelDataServiceProvider, because in the service provider binds the class and always uses the Request (except when you have your own bind defined).

        /** @psalm-suppress UndefinedInterfaceMethod */
        $this->app->beforeResolving(BaseData::class, function ($class, $parameters, $app) {
            if ($app->has($class)) {
                return;
            }

            $app->bind(
                $class,
                fn ($container) => $class::from($container['request'])
            );
        });

However, the parameter $paremeters is never used.

DataCollectionOf cast ignores global custom cast

Hey there, I am encountering an issue when using the DataCollectionOf attribute together with an abstract class and custom cast.

Basically the attribute is used like this:

#[DataCollectionOf(MessageContent::class)]

Whereas MessageContent is an abstract class. A custom cast takes care of creating the correct concrete object. This works as long as I typehint a single instance:

public MessageContent $content

But now I have a collection of these MessageContent objects. Basically the attribute is used like this:

#[DataCollectionOf(MessageContent::class)]
public DataCollection $contents

Now I get the following error:

Error: Cannot instantiate abstract class Namespace\MessageContent in file /path/vendor/spatie/laravel-data/src/Resolvers/DataFromArrayResolver.php on line 60

My guess is that the DataFromArrayResolver somehow ignores the custom cast config in data.php.

Only, except, include, exclude in a query string ignores MapOutputName and MapName (CaseMapper)

Description

(!) The following model, data object, and controller are only simplified to demonstrate the problem.

There is a simple eloquent model:

<?php
declare(strict_types=1);

namespace Billing\Models;

use App\Models\BaseModel;
use Billing\DataObjects\InvoiceDataObject;

class Invoice extends BaseModel
{
    protected string $dataClass = InvoiceDataObject::class;

    protected $table = 'invoices';

    protected $dates = [
        'expires_at'
    ];

    protected $fillable = [
        'id',
        'paid_amount',
        'expires_at',
    ];

}

Data object:

<?php
declare(strict_types=1);

namespace Billing\DataObjects;

#[MapName(SnakeCaseMapper::class)]
class InvoiceDataObject extends DataObject
{
    public function __construct(
        public Optional|Lazy|string $id,
        public Optional|Lazy|string $paidAmount,
        #[MapOutputName('due_at')]
        #[WithCast(DateTimeInterfaceCast::class)]
        public Optional|Lazy|\DateTime $expiresAt
    )
    { 
    }

    public static function allowedRequestIncludes(): ?array
    {
        return [
            'id',
            'paidAmount',
            'expiresAt'
        ];
    }

    public static function allowedRequestOnly(): ?array
    {
        return [
            'id',
            'paidAmount',
            'expiresAt'
        ];
    }

    public static function allowedRequestExcept(): ?array
    {
        return [
            'id',
            'paidAmount',
            'expiresAt'
        ];
    }

    public static function allowedRequestExcludes(): ?array
    {
        return [
            'id',
            'paidAmount',
            'expiresAt'
        ];
    }

    public function includeProperties(): array
    {
        return [
            'id'                    => true,
            'paidAmount'  => true,
            'expiresAt'       => true,
        ];
    }

    public static function fromModel(Invoice $record): self
    {
        $data = $record->toArray();
        $lazyModel = [];
        foreach ($data as $key => $value) {
           $lazyModel[$key] = Lazy::create(fn() => $value);
        }

        return self::from(
            [
                ...$lazyModel
            ]
        );
    }
}

And simple controller:

<?php
declare(strict_types=1);

namespace Billing\V1\Http\Controllers;

use App\Http\Controllers\Controller;
use Billing\Models\Invoice;
use Billing\V1\Http\Requests\InvoiceGetRequest;

class InvoiceController extends Controller
{
    /**
     * Return invoice
     *
     * @param InvoiceGetRequest $request
     * @param Invoice $invoice
     */
    public function get(InvoiceGetRequest $request, Invoice $invoice)
    {
        return $invoice->getData()->toResponse($request);
    }
}

The base response for this action looks like this:

{
    "id": "a502674e-3f74-4d73-a839-cdfcfcf76fed",
    "paid_amount": "0.00",
    "due_at": "2023-02-09T18:21:54.753084Z"
}

Case 1: Case Mapper ignoring (#[MapName(SnakeCaseMapper::class)])

Ok. Let's try to make the request to this action using query string params:

GET https://.../v1/billing/invoices/a502674e-3f74-4d73-a839-cdfcfcf76fed?except=paid_amount

The response still contains paid_amount:

{
    "id": "a502674e-3f74-4d73-a839-cdfcfcf76fed",
    "paid_amount": "0.00",
    "due_at": "2023-02-09T18:21:54.753084Z"
}

Ok, let's try to repeat the request but with ?except=paidAmount:

GET https://.../v1/billing/invoices/a502674e-3f74-4d73-a839-cdfcfcf76fed?except=paidAmount

The response will be without paid_amount:

{
    "id": "a502674e-3f74-4d73-a839-cdfcfcf76fed",
    "due_at": "2023-02-09T18:21:54.753084Z"
}

It seems that when using CaseMapper, filtering by query string does not take into account the actual mapping.

Case 2: MapOutputName ignoring (#[MapOutputName('due_at')])

Let's try to make the request to this action using query string params:

GET https://.../v1/billing/invoices/a502674e-3f74-4d73-a839-cdfcfcf76fed?except=due_at

The response still contains due_at:

{
    "id": "a502674e-3f74-4d73-a839-cdfcfcf76fed",
    "paid_amount": "0.00",
    "due_at": "2023-02-09T18:21:54.753084Z"
}

Let's continue using the query with ?except=dueAt:

GET https://.../v1/billing/invoices/a502674e-3f74-4d73-a839-cdfcfcf76fed?except=dueAt

The response still contains due_at:

{
    "id": "a502674e-3f74-4d73-a839-cdfcfcf76fed",
    "paid_amount": "0.00",
    "due_at": "2023-02-09T18:21:54.753084Z"
}

It seems, that it's necessary to say to the mobile app developer, that due_at is equal to expires_at and he must use ?except=expiresAt in the query. Looks strange, but it works:

GET https://.../v1/billing/invoices/a502674e-3f74-4d73-a839-cdfcfcf76fed?except=expiresAt

Now no due_at in the response.

{
    "id": "a502674e-3f74-4d73-a839-cdfcfcf76fed",
    "paid_amount": "0.00",
}

All these cases look like bugs. Or maybe I'm wrong?

Nested DataCollections very slow

Loading a data object with nested relations (parent/child) can get very slow with large datasets.

Given the following classes:

class BlockElementDO extends BaseDataObject
{
    /**
     * @var DataCollection<BlockElementDO>|null
     */
    public ?DataCollection $blockElements = null;

    public int $id;
}
class BlockElement extends Model
{
    use HasFactory;

    public function blockElements(): HasMany
    {
        return $this->hasMany($this::class, 'parent_id');
    }
}

As you can see a block element can have a collection of block elements (parent/child).So in theory a block element can have infinite nested block elements.

I noticed a massive performance drop (200% increase in loading time) when loading data objects in nested collections than in contrast with loading data objects in a flattened collection. The workaround is using normal arrays but id rather stick to the data collections.

Thanks for all the material you guys put out there!

Class given does not implement DataObject::class

This is what I'm doing

`

class SignupData extends Data
{

public function __construct(
    readonly public string $email,
    readonly public string $password,
    readonly public string $first_name,
    readonly public string $last_name,

    #[DataCollectionOf(ServiceData::class)]
    readonly public DataCollection $personal_service_data,
) {
}

}`

And this is my ServiceData Class

`
class PersonalServiceData extends Data implements DataObject
{

public function __construct( 
    readonly public string $dob,
    readonly public string $state,
    readonly public string $gender,

) {
}

}`

This is throwing me this following error:

Spatie\LaravelData\Exceptions\CannotFindDataClass: Class given does not implement DataObject::class in file /vendor/spatie/laravel-data/src/Attributes/DataCollectionOf.php on line 17

I have tried implementing the DataObject Class to both of these classes but the issue was not fixed.

Support for Inertia's "first visit" aware lazy loading?

I have set up laravel-data to work with our Inertia app, but we were making use of Inertia's ability to load data on first load, but then automatically swap to lazy loading for subsequent loads. See their documentation here: https://inertiajs.com/partial-reloads#lazy-data-evaluation

return Inertia::render('Users/Index', [
    // ALWAYS included on first visit...
    // OPTIONALLY included on partial reloads...
    // ALWAYS evaluated...
    'users' => User::get(),

    // ALWAYS included on first visit...
    // OPTIONALLY included on partial reloads...
    // ONLY evaluated when needed...
    'users' => fn () => User::get(),

    // NEVER included on first visit...
    // OPTIONALLY included on partial reloads...
    // ONLY evaluated when needed...
    'users' => Inertia::lazy(fn () => User::get()),
]);

Note the second usage in their example which is to pass a callable instead of their Lazy object.,

This is particularly useful for loading a somewhat complex page, but then having individual components able to update and pass only: [...] to the request to choose which parts of the response to include.

Am I correct in understanding that laravel-data doesn't currently support this?

Using cursorPaginate with orderBy and camelCased properties leads to errors.

Loving the Laravel data package. Really appreciate all the work you've put into it.

One snag I ran into was using cursorPaginated collections with orderBy and camelCased properties. Given the following data object:

use App\Models\User;
use Spatie\LaravelData\Data;

class UserData extends Data
{
    public function __construct(
        public string $id,
        public string $name,
        public string $createdAt,
    ) {
    }

    public static function fromModel(User $user): UserData
    {
        return new self(
            id: $user->id,
            name: $user->name,
            createdAt: $user->created_at,
        );
    }
}

I get

ErrorException : Undefined property: App\Data\UserData::$created_at` 

When using the cursor paginator like this:

        $users = User::orderBy('created_at', 'DESC')->cursorPaginate(5);
        $userData = UserData::collection($users);

        // ErrorException : Undefined property: App\Data\UserData::$created_at
        $array = $userData->toArray();

Everything seems to work fine with paginate and simplePaginate. I reproduced the issue in a fresh Laravel install here: https://github.com/benbjurstrom/laravel-data-issue/blob/main/tests/Feature/ExampleTest.php

Let me know if you need any additional information. Thanks!

Optional nested objects not working correctly

I have the following code. The data object is created from a request which may or may not have an address in the payload.

class NewRegularUserDto extends Data
{
    public function __construct(
        #[Required] #[Email]
        public string $email,
        #[Required] #[StringType]
        public string $role,
        #[Required]
        public ProfileDto $profile,
        #[Sometimes]
        public AddressDto|Optional $address,
    ) {
    }
}

When it doesn't have an address in the payload I would expect it to create the data object without an address but it is throwing a validation error related to the validation in the AddressDTO. I also tried
public ?AddressDto $address = null,

But it still tries to validate an address and returns an error.

How do I make it so that if there is an address in the payload it is validation and if there is no address then do not try to validate it and set $address to null. Is this linked to Issue #441 ?

Validation: Custom attributes names of DataCollections not working

Consider this data class:

class RelationData extends Data
{
    public function __construct(
        public string      $name,

        #[DataCollectionOf(AddressData::class)]
        public DataCollection $addresses
    )
    {
    }

    public static function attributes(...$args): array
    {
        return [
            'name' => 'naam'
        ];
    }
}

It has a DataCollection of AddressData:

class AddressData extends Data
{
    public ?int $id;

    public ?string $code;

    public ?string $name;

    public string $street;

    public string $number;

    public string $city;

    #[DataCollectionOf(ContactData::class)]
    public DataCollection $contacts;

    public static function attributes(...$args): array
    {
        return [
            'code' => 'code',
            'street' => 'straat',
            'number' => 'huisnummer',
            'zipcode' => 'postcode',
            'city' => 'plaats'
        ];
    }
}

The validation error returned for address.2.city is: Het addresses.2.city veld is verplicht. and not Het plaats veld is verplicht.
Also Address has a DataCollection of ContactData that also doens't work.

What I found was that ContactData attributes where not even added. Because of this line:

if (Arr::has($fullPayload, $propertyPath->get()) === false) {

Adding: !$dataProperty->type->isDataCollectable makes sure the attributes are added.
But they are still not resolved.

I stranded to find out where the resolving took place.

Anyone that had the same issue?

Case mappers don't work as (presumably) intended.

Using Win 11 / WSL 2 (Ubuntu) / PHP 8.1.12.

Test usage:

$tmp = json_decode($employeesJson, true)[0];
$data = Employee::from($tmp);
rd($tmp, $data);

👍 works

use Spatie\LaravelData\Attributes\MapInputName;
use Spatie\LaravelData\Data;

class EmployeeData extends Data
{
    public function __construct(
        #[MapInputName('Id')]                   public ?int $id = null,
        #[MapInputName('ReferenceId')]          public ?string $referenceId = null,
        #[MapInputName('OrganizationalUnitId')] public ?int $organizationalUnitId = null,
        #[MapInputName('Active')]               public ?bool $active = null,
    ) { }
}
/* Ray output
array:32 [▼
  "Id" => 69
  "ReferenceId" => "552"
  "OrganizationalUnitId" => 123011821
  "Active" => true
]
App\Data\EmployeeData {#3342 ▼
  +id: 69
  +referenceId: "552"
  +organizationalUnitId: 123011821
  +active: true
}
*/

👎doesn't

use Spatie\LaravelData\Attributes\MapInputName;
use Spatie\LaravelData\Data;
use Spatie\LaravelData\Mappers\CamelCaseMapper;

#[MapInputName(CamelCaseMapper::class)]
class EmployeeData extends Data
{
    public function __construct(
        public ?int $id = null,
        public ?string $referenceId = null,
        public ?int $organizationUnitId = null,
        public ?bool $active = null,
    ) { }
}
/* Ray output
array:32 [▼
  "Id" => 69
  "ReferenceId" => "552"
  "OrganizationalUnitId" => 123011821
  "Active" => true
]
App\Data\EmployeeData {#3342 ▼
  +id: null
  +referenceId: null
  +organizationalUnitId: null
  +active: null
}
*/

Perhaps I just misunderstood the intended usage or something...

Request resolving behaves differently than ::from([])

Either I'm missing something completely from the docs (believe me I've read them thoroughly) or this is a "bug".

In a specific situation where a (legacy) system returns a set of keys which are "related" (by name) I want to Cast this to a single Object for clarity/cleaniness.

e.g.:

$payload = [
    'expecteddeliverydatetime' => '2022-11-21',
    'expecteddeliverywarehouse => 'Location X',
    ...
]

I created a Caster that "reacts" to the first input and returns a clean ExpectedDelivery object.

When doing this from the ::from($inputArray) context my Caster is invoked. When asking for the DataObject as a Dependency Injected Route parameter, my Caster is never invoked and your package somehow validates my nested Object.

Some example code (I can reproduce this in a clean Laravel instance):

<?php

declare(strict_types=1);

namespace App\Data;

use Spatie\LaravelData\Attributes\MapInputName;
use Spatie\LaravelData\Attributes\Validation\Required;
use Spatie\LaravelData\Attributes\WithCast;
use Spatie\LaravelData\Data;

final class ObjectOne extends Data
{
    public function __construct(
        #[Required]
        public readonly string $name,

        #[WithCast(ObjectTwoCaster::class)]
        #[MapInputName('foobar')]
        public readonly ObjectTwo $foo,
    ) {
    }
}
<?php

declare(strict_types=1);

namespace App\Data;

use Spatie\LaravelData\Casts\Cast;
use Spatie\LaravelData\Support\DataProperty;

final class ObjectTwoCaster implements Cast
{
    public function cast(DataProperty $property, mixed $value, array $context): ObjectTwo
    {
        $keysToKeep = [
            'foobar',
            'foobaz',
        ];

        $valuesToKeep = \array_filter(
            $context,
            function ($key) use ($keysToKeep) {
                return \in_array($key, $keysToKeep, true);
            },
            ARRAY_FILTER_USE_KEY,
        );

        $renamedKeyValues = [];
        foreach ($valuesToKeep as $key => $val) {
            $renamedKey = \str_replace('foo', '', $key);
            $renamedKeyValues[$renamedKey] = $val;
        }

        return ObjectTwo::from($renamedKeyValues);
    }
}
<?php

declare(strict_types=1);

namespace App\Data;

use Spatie\LaravelData\Data;

final class ObjectTwo extends Data
{
    public function __construct(
        public readonly string $bar,
        public readonly string $baz,
    ) {
    }
}

With a Postman call containing:

{
    "name": "spatie-laravel-data"
    "foobar": "ValueOne"
    "foobaz": "ValueTwo"
}

The situation that works:

//...
public function fooGet(Request $request)
{
    $data = ObjectOne::from($request->toArray());
}

The situation that fails:

//...
public function fooGet(ObjectOne $data)
{
    //...
}

Is there something I'm missing here? Why does it behave differently?

Lost partial tree of nested data object after `all` method call

Structure

class ArtistData extends Data
{
    public function __construct(
        public string $name,
        public int $age,
    ) {
        if ($this->name === 'Jansu') {
            $this->only('name');
        }
    }
}

class AlbumData extends Data
{
    public function __construct(
        public string $title,
        public ArtistData $artist,
    ) {
    }
}

The code where the bug occurs

// $data is instance of AlbumData with all properties filled and Artist name is Jansu

ray($data->artist->getPartialTrees()->only); // has 1 children (name) 👍
$data->all();
ray($data->artist->getPartialTrees()->only); // has 0 children 😯

even if you do so, the tree is also lost

// $data is instance of AlbumData with all properties filled

$data->artist->only('name');
ray($data->artist->getPartialTrees()->only); // has 1 children (name) 👍
$data->all();
ray($data->artist->getPartialTrees()->only); // has 0 children 😯

Validation of optional nested Data attribute is impossible

Consider a CreateAddressData object defined as follows:

class CreateAddressData extends Data {
  public function __construct(
    public readonly string $street,
    public readonly string $nbr,
    public readonly string $postal_code,
    public readonly string $city,
    public readonly string $cc
  ) {
  }

  public static function rules() {
    return [
      'street' => 'required|min:1|max:80',
      'nbr' => 'required|max:20',  
      'postal_code' => 'required|min:4|max:20',
      'city' => 'required|min:1|max:50',
      'cc' => 'required|size:2',
    ];
  }
}

And we have a CreatePersonData with the following definition:

class CreatePersonData extends Data {
  public function __construct(
    public readonly string $first_name,
    public readonly string $last_name,
    public readonly string $email,
    public readonly ?CreateAddressData $address // Please note the address is OPTIONAL
  ) {
  }

  public static function rules() {
    return [
      'first_name' => 'required|min:1|max:50',
      'last_name' => 'required|min:1|max:50',
      'email' => 'required|email:rfc',
      'address' => '', // No validation rules for the address, as it's considered optional!
    ];
  }

The idea being that a person can optionally have an address, but when an address is given, the properties are correctly being validated...

The following code however throws a validation exception saying The address.street field is required. (and 4 more errors):

  CreatePersonData::validate(
    [
      'first_name' => $value,
      'last_name' => fake()->lastName(),
      'email' => fake()->email(),
    ]
  );

Shouldn't the nested data object only be validated when it is required in the containing data object or when it's optional and provided as part of the containing object?

I haven't checked the code yet (will do asap though), but Im only guessing you are merging the validation rules from the nested objects into the rules array for the containing one, but we should probably loop-validate instead, no?

Unable to add a `where` clause to the `Unique` rule

Spatie\LaravelData\Attributes\Validation\Unique attribute accepts ?Closure for the $where parameter, but providing a function to that parameter throws an exception:

Constant expression contains invalid operations

E.g.

class MyDataObject extends Data
{
    public function __construct(
        #[Max(255), Unique(Model::class, 'param_id', where: fn (Builder $query) => $query->where('type', 'value'))]
        public string $param,
    }
}

Error while writing the test

If I use DataObject in Unit Test, I Have this error
Illuminate\Contracts\Container\BindingResolutionException: Unresolvable dependency resolving [Parameter #0 [ <required> array $config ]] in class Spatie\LaravelData\Support\DataConfig

validation fails for multi type or mixed type parameter without rules present

I'm using version 3.5.1

Having a minimal dto like this and running validateAndCreate returns validation errors for valid inputs

class MinimalDto extends Data
{
    public function __construct(
        public null|int|array $input_id,
    ) {
    }
}
MinimalDto::validateAndCreate(['input_id' => 1]); // The input id must be an array.

MinimalDto::validateAndCreate(['input_id' => [1]]); // The input id must be a number.

MinimalDto::validateAndCreate(['input_id' => null]); // ok

When null is not allowed, then can not be created at all.

Same happens with mixed type

class MinimalDto extends Data
{
    public function __construct(
        public mixed $input_id,
    ) {
    }
}
  "input_id" => array:3 [
    0 => "The input id must be a string."
    1 => "The input id field must be true or false."
    2 => "The input id must be an array."
  ]

I have a custom validator made to validate IntOrArrayOfInt.

When using the #[Rule(new IntOrArrayOfInt)] attribute, the same problem is present.

Currently the workaround is to add to the class the rules function, but for me that was not very intuitive and seems like a bug

class MinimalDto extends Data
    {
    public function __construct(
        public null|int|array $input_id,
    ) {
    }

    public static function rules(): array
    {
        return [
            // for some reason can not use #[Rule(...)] attribute, still it tries to always enforce "array" AND "int"
            'input_id' => ['nullable', new IntOrArrayOfInt()],
        ];
    }
}    

Freeze when using recursive data annotations

I'm using a structure akin to, my code is of course a bit more involved but this is the basic idea

class Item extends Data {
  public function __construct(
    public int $item_id,
    public int $quantity,
    #[DataCollectionOf(Item::class)]
    public ?DataCollection $subItems) {}
}

As you can see, my Item optionally nests a collection of Items, but this freezes. The request simply times out.

Is it possible there's an accidental infinite loop in the code due to this annotation?

"Present" arrays are not handled correct

I have the following request:

    public function rules(): array
    {
        return [
            'values' => [
                'present',
                'array',
            ],
        ];
    }

    protected function prepareForValidation()
    {
        $this->merge([
            'values' => $this->filled('values')
                ? json_decode($this->input('values'), true)
                : []
        ]);
    }

and the following dto:

class MyData extends Data
{
    public function __construct(
        public array $values = []
    ) {}
}

when running this test:

        $x = $this->postJson('test', [
            //'values' => json_encode(['date' => '2023-12-31'])
        ]);

        dd($x->content());

I get the following error out of LaravelData: {"message":"The values field is required.","errors":{"values":["The values field is required."]}} which is not what I expect. There should be an empty array.

Adding Optional -> public array|Optional $values = [] fixes the error, but gives me an empty string on $data->values which is also not what it sould be.

I tried to add #[Present] to the $values property, but the error message will be the same.

It seems to be a bug because LaravelData is breaking PHPs typesafty or is not compatible with Laravels validation.

Would be awesome if you could take a look into that.

Incorrect `$payload` content in parameters of `rule` method

Structure

class ArtistData extends Data
{
    public function __construct(
        public string $name,
        public int $age,
    ) {
    }
}

class AlbumData extends Data
{
    public function __construct(
        public string $title,
        public ArtistData $artist,
    ) {
    }
}

There is a need to build rules depending on the payload in ArtistData.
It's easy according to the docs.

    public static function rules(array $payload): array
    {
        ray($payload);
        return [];
    }

I thought this payload is relative to the data class, but no 🦊

Suppose I have 2 endpoints: create an artist and create an album.
In both cases, this payload will be different and will actually repeat the request body.

In case create an album it will be

[
  "title" => "title"
  "artist" => [
    "name" => "name"
    "age" => 20
  ]
]

But ArtistData doesn't know what key is used in AlbumData.

Is this correct behavior or is something wrong?

Constructor with Optional Parameters not working properly

Hi everyone.

I've created a data class with 6 optional parameters:

class OrdersFromCartData extends Data
{
    public function __construct(
        public string          $application_id,
        public string|Optional $insurance_id,
        public int|Optional    $installment_period,
        public int|Optional    $city_id,
        public string|Optional $address,
        public string|Optional $note,
        public string|Optional $house_number,
    )
    {
    }
}

Then I'm initializing it like this:

$orderData = OrdersFromCartData::from(
            [
                'insurance_id' => 1,
                'application_id' => '1234567890',
                'installment_period' => 12
            ]
        );

However, it is showing

Could not create App\\DTOs\\Orders\\OrdersFromCartData: the constructor requires 7 parameters, 3 given.Parameters given: application_id, insurance_id, installment_period.

From the documentation it is not expected behaviour

Edit: I've also tried rearranging parameters like this

Then I'm initializing it like this:

$orderData = OrdersFromCartData::from(
            [
               'application_id' => '1234567890',
               'insurance_id' => 1,
               'installment_period' => 12
            ]
        );

But it didn't help

Strangely, I used and had no problems with optional parameters before

Edit 2: I've also noticed that I'm passing insurance_id as int, so I've changed the parameter type in the constructor. But problem didn't solved

Magic `from()` methods are called only after validation when `validateAndCreate()` is invoked. So it's always marked as invalid.

<?php

namespace Tests\Unit;

use Spatie\LaravelData\Data;
use Tests\TestCase;

class Example3Test extends TestCase
{
    public function test_magic_from(): void
    {
        $songName = '[[ SONG NAME ]]';
        $song     = Song::from(['title' => $songName]);

        // Test begins here
        dump('first');
        $t = HitSong::from($song); // magic method `fromS` invoked as evident in output below
        dump($t->title_of_hit_song);
        self::assertEquals($songName, $t->title_of_hit_song);

        dump('second');
        $t = HitSong::validateAndCreate($song); // magic method not invoked, the title_of_hit_song is null
        dump($t->title_of_hit_song);
        self::assertEquals($songName, $t->title_of_hit_song);
    }
}

class Song extends Data
{
    public string $title;
}

class HitSong extends Data
{
    public string $title_of_hit_song;

    public static function fromS(Song $s): self
    {
        dump('custom method called');
        $o                    = new self();
        $o->title_of_hit_song = $s->title;

        return $o;
    }
}

output:

"first" // tests/Feature/asterisk/AsteriskTest.php:74
"custom method called" // tests/Feature/asterisk/AsteriskTest.php:97
"[[ SONG NAME ]]" // tests/Feature/asterisk/AsteriskTest.php:76
"second" // tests/Feature/asterisk/AsteriskTest.php:79

Illuminate\Validation\ValidationException : title of hit song is required
 /var/www/vendor/laravel/framework/src/Illuminate/Support/helpers.php:327
 /var/www/vendor/laravel/framework/src/Illuminate/Validation/Validator.php:495
 /var/www/vendor/spatie/laravel-data/src/Concerns/ValidateableData.php:30
 /var/www/vendor/spatie/laravel-data/src/Concerns/ValidateableData.php:52

So the only difference from issue #394 code is: public string $title_of_hit_song; - it's no longer optional.

It has to be present, and the custom from method sets it properly. However, because the validation is launched first, it never comes to that, effectively NEVER calling the magic method and ALWAYS throwing validation exception, making it IMPOSSIBLE for the magic from method to be invoked via validateAndCreate if any validation rules are present.

exclude, exclude_with rules not working

Note: this is an oversimplification to show the issues I have been experiencing with exclude , exclude_with, and other exclude... rules in Data objects.

Given the form:

<form method="POST" action="/emailAddresses">
    @csrf
    Email: <input type="email" name="email" value="[email protected]" /><br />
    MD5: <input type="text" name="email_md5" value="1234567890abcdef1234567890abcdef" /><br />
    <input type="submit" name="submit" value="Submit" />
</form>

the route:

Route::post('/emailAddresses', [EmailAddressController::class, 'store']);

the EmailAddressController store function:

public function store(EmailAddressData $emailAddressData)
{
    $theData = $emailAddressData->toArray();
    dd($theData);
}

the EmailAddressData DTO:

<?php
namespace App\Data;
use Spatie\LaravelData\Data;

class EmailAddressData extends Data
{
    public function __construct(
        public readonly ?string $email,
        public readonly ?string $email_md5,
    ) {}

    public static function rules()
    {
        return [
            'email' => ['email'],
            'email_md5' => ['exclude_with:email'],
        ];
    }
}

dd($theData); shows:

array:2 [▼ // app/Http/Controllers/EmailAddressController.php:42
  "email" => "[email protected]"
  "email_md5" => "1234567890abcdef1234567890abcdef"
]

Note that email_md5 is not excluded. Even using the email_md5 rule as exclude fails to exclude the email_md5 from the validated data.

However, using the exact same rules in a FormRequest does exclude the md5_email from the validated data:

public function store(StoreEmailAddressRequest $request)
{
    $theData = $request->validated();
    dd($theData);
}
<?php
namespace App\Http\Requests;
use Illuminate\Foundation\Http\FormRequest;

class StoreEmailAddressRequest extends FormRequest
{
    public function authorize(): bool
    {
        return true;
    }

    public function rules()
    {
        return [
            'email' => ['email'],
            'email_md5' => ['exclude_with:email'],
        ];
    }
}

dd($theData) shows:

array:1 [▼ // app/Http/Controllers/EmailAddressController.php:42
  "email" => "[email protected]"
]

Only, except, include, etc. still allows getting object properties

$user = UserData::from(User::first())->only('id');

$user->name // prints name
$user->password->resolve() // prints password

The main issue seems to be all properties are still being included on the data object. I would expect it simply doesn't include them in the final object:

App\DataObjects\UserData {
  #_partialTrees: null
  #_includes: []
  #_excludes: []
  #_only: array:1 [▼
    "id" => true
  ]
  #_except: []
  #_additional: []
  #_wrap: null
  +id: 1
  +email: Spatie\LaravelData\Support\Lazy\DefaultLazy {#2169 ▶}
  +password: Spatie\LaravelData\Support\Lazy\DefaultLazy {#2168 ▶}
  +firstname: Spatie\LaravelData\Support\Lazy\DefaultLazy {#2166 ▶}
  +lastname: Spatie\LaravelData\Support\Lazy\DefaultLazy {#2164 ▶}

Expected result:

App\DataObjects\UserData {
  #_partialTrees: null
  #_includes: []
  #_excludes: []
  #_only: array:1 [▼
    "id" => true
  ]
  #_except: []
  #_additional: []
  #_wrap: null
  +id: 1

I don't know if this causes some security issues, as in Livewire as they are reactive.

Please let me know you thoughts.

ValidationRule wrong use

use Spatie\LaravelData\Support\Validation\ValidationRule;

public function __construct(string|array|ValidationRule|RuleContract|InvokableRuleContract ...$rules)

My laravel version: 10.x

My rule:

<?php

namespace App\Rules;

use Closure;
use Illuminate\Contracts\Validation\ValidationRule;

class MyRule implements ValidationRule
{
    /**
     * Run the validation rule.
     *
     * @param string $attribute
     * @param mixed $value
     * @param Closure $fail
     */
    public function validate(string $attribute, mixed $value, Closure $fail): void
    {

    }


}

My usage:

<?php

use App\Rules\MyRule;
use Spatie\LaravelData\Attributes\Validation\Required;
use Spatie\LaravelData\Attributes\Validation\Rule;
use Spatie\LaravelData\Data;

class LoginRequest extends Data
{
    public function __construct(
        #[Required]
        #[Rule([new MyRule])]
        public string $login,
    )
    {
    }
}

Livewire - unable to call lazy data

$this->findUserOrFail(1)
    ->getData() // I'm using the data trait
    ->include(
           'name',
           'email',
    );

It doesn't seem the include is being parsed/loaded.

Result:

htmlspecialchars(): Argument #1 ($string) must be of type string, Spatie\LaravelData\Support\Lazy\DefaultLazy given

My blade simple uses: {{ $user->email }}

If more info is needed, please let me know!

Thanks

Local Casts for Spatie Laravel Enum in Data Object isn't working

Hello,

I can't get the enum casting example to work when creating a data object from an array

I'm using the following versions

Laravel 10
"spatie/laravel-data": "^3.1.0",
"spatie/laravel-enum": "^3.0.2",

First I want to mention that I also tried the exact example as presented in the docs:
https://spatie.be/docs/laravel-data/v3/as-a-data-transfer-object/casts

I got the same error.

I create a data object like this:
Screenshot 2023-03-01 at 12 03 01 PM

My Enum looks like this:
Screenshot 2023-03-01 at 12 02 37 PM

Now I'm trying to create a data object from an array like so:
Screenshot 2023-03-01 at 12 03 13 PM

Unfortunately I get the following error:
Screenshot 2023-03-01 at 12 03 30 PM

What am I missing here? Like mentioned above I also tried the exact example from the docs and I get the same error.

Recommend Projects

  • React photo React

    A declarative, efficient, and flexible JavaScript library for building user interfaces.

  • Vue.js photo Vue.js

    🖖 Vue.js is a progressive, incrementally-adoptable JavaScript framework for building UI on the web.

  • Typescript photo Typescript

    TypeScript is a superset of JavaScript that compiles to clean JavaScript output.

  • TensorFlow photo TensorFlow

    An Open Source Machine Learning Framework for Everyone

  • Django photo Django

    The Web framework for perfectionists with deadlines.

  • D3 photo D3

    Bring data to life with SVG, Canvas and HTML. 📊📈🎉

Recommend Topics

  • javascript

    JavaScript (JS) is a lightweight interpreted programming language with first-class functions.

  • web

    Some thing interesting about web. New door for the world.

  • server

    A server is a program made to process requests and deliver data to clients.

  • Machine learning

    Machine learning is a way of modeling and interpreting data that allows a piece of software to respond intelligently.

  • Game

    Some thing interesting about game, make everyone happy.

Recommend Org

  • Facebook photo Facebook

    We are working to build community through open source technology. NB: members must have two-factor auth.

  • Microsoft photo Microsoft

    Open source projects and samples from Microsoft.

  • Google photo Google

    Google ❤️ Open Source for everyone.

  • D3 photo D3

    Data-Driven Documents codes.