Build With Abdallah logo Build With Abdallah Software · AI · Automation
Tutorial 3 min read Jun 05, 2026

Building a Laravel Package for Data Transfer Objects

Build a reusable Laravel DTO package with Composer autoloading, service provider discovery, tests, and local installation in a demo app.

A
Abdallah Mohamed
Senior Full-Stack Engineer
Building a Laravel Package for Data Transfer Objects

Building a Laravel Package for Data Transfer Objects

Laravel packages are useful when the same code keeps showing up in more than one app.

That might be a billing helper, an admin dashboard widget, a custom validation rule, a set of API clients, or a data object layer. The package does not need to be huge. In fact, the best first package is usually small: one clear responsibility, a service provider, tests, and a clean installation path.

In this tutorial, we will build a Laravel package that provides typed Data Transfer Objects, or DTOs. The package will let an app turn request data into predictable objects instead of passing raw arrays through controllers, services, jobs, and API clients.

The package will include:

  • a DataTransferObject base class
  • a concrete UserProfileData DTO
  • a Laravel service provider
  • Composer autoloading
  • PHPUnit tests
  • local installation into a Laravel app through a Composer path repository

This is based on the package-development workflow covered by Laravel News and Laravel's official package documentation, but we will build a real package instead of stopping at configuration.

What This Package Does

The package gives Laravel apps a small typed data layer.

Instead of doing this:

$data = [
    'name' => $request->input('name'),
    'email' => $request->input('email'),
    'timezone' => $request->input('timezone', 'UTC'),
];

$profileService->update($user, $data);

You can do this:

$profile = UserProfileData::fromArray($request->validated());

$profileService->update($user, $profile);

That matters because the service receives a known object with known properties. The object can normalize defaults, reject missing data, and expose a safe toArray() method when you need to store or serialize it.

Prerequisites

You need:

  • PHP 8.2 or newer
  • Composer
  • Laravel 11 or Laravel 12 for the demo app
  • Basic comfort with namespaces and Composer autoloading

Create a workspace:

mkdir laravel-dto-package-demo
cd laravel-dto-package-demo

Inside this workspace, we will create two projects:

laravel-dto-package-demo/
├── packages/
│   └── buildwithabdallah/
│       └── laravel-data-objects/
└── demo-app/

The package lives under packages/. The Laravel app lives under demo-app/ and installs the package locally.

Step 1: Create the Package Directory

Create the package folders:

mkdir -p packages/buildwithabdallah/laravel-data-objects/src/Data
mkdir -p packages/buildwithabdallah/laravel-data-objects/src/Providers
mkdir -p packages/buildwithabdallah/laravel-data-objects/tests
cd packages/buildwithabdallah/laravel-data-objects

Create composer.json:

{
  "name": "buildwithabdallah/laravel-data-objects",
  "description": "Typed data transfer objects for Laravel applications.",
  "type": "library",
  "license": "MIT",
  "require": {
    "php": "^8.2",
    "illuminate/support": "^11.0|^12.0"
  },
  "require-dev": {
    "orchestra/testbench": "^9.0|^10.0",
    "phpunit/phpunit": "^10.5|^11.0"
  },
  "autoload": {
    "psr-4": {
      "BuildWithAbdallah\\DataObjects\\": "src/"
    }
  },
  "autoload-dev": {
    "psr-4": {
      "BuildWithAbdallah\\DataObjects\\Tests\\": "tests/"
    }
  },
  "extra": {
    "laravel": {
      "providers": [
        "BuildWithAbdallah\\DataObjects\\Providers\\DataObjectsServiceProvider"
      ]
    }
  },
  "minimum-stability": "stable",
  "prefer-stable": true
}

The important parts are autoload and extra.laravel.providers.

autoload tells Composer where your package classes live. The Laravel extra block enables package auto-discovery, so Laravel can register the service provider when the package is installed.

Install dependencies:

composer install

Step 2: Create the Base DTO Class

Create src/Data/DataTransferObject.php:

<?php

namespace BuildWithAbdallah\DataObjects\Data;

use InvalidArgumentException;
use JsonSerializable;
use ReflectionClass;

abstract class DataTransferObject implements JsonSerializable
{
    public static function fromArray(array $data): static
    {
        $class = new ReflectionClass(static::class);
        $constructor = $class->getConstructor();

        if ($constructor === null) {
            return new static();
        }

        $arguments = [];

        foreach ($constructor->getParameters() as $parameter) {
            $name = $parameter->getName();

            if (array_key_exists($name, $data)) {
                $arguments[$name] = $data[$name];
                continue;
            }

            if ($parameter->isDefaultValueAvailable()) {
                $arguments[$name] = $parameter->getDefaultValue();
                continue;
            }

            throw new InvalidArgumentException("Missing required DTO field [{$name}].");
        }

        return new static(...$arguments);
    }

    public function toArray(): array
    {
        return get_object_vars($this);
    }

    public function jsonSerialize(): array
    {
        return $this->toArray();
    }
}

This base class gives every DTO three useful methods:

  • fromArray() builds the DTO from validated request data.
  • toArray() converts public promoted properties back to an array.
  • jsonSerialize() makes the DTO safe to pass into json_encode().

The reflection logic keeps the package small. For a production package, you could add nested DTO support, custom casting, and stricter validation. For this tutorial, the goal is a clear package foundation.

Step 3: Create a Concrete DTO

Create src/Data/UserProfileData.php:

<?php

namespace BuildWithAbdallah\DataObjects\Data;

final class UserProfileData extends DataTransferObject
{
    public function __construct(
        public readonly string $name,
        public readonly string $email,
        public readonly string $timezone = 'UTC',
    ) {
    }
}

This DTO represents the shape of a profile update. It is small, typed, and easy to pass through the application.

The readonly properties are useful because they prevent accidental mutation after the object is created. If a service needs changed data, create a new DTO instead of modifying the existing one.

Step 4: Add a Laravel Service Provider

Create src/Providers/DataObjectsServiceProvider.php:

<?php

namespace BuildWithAbdallah\DataObjects\Providers;

use Illuminate\Support\ServiceProvider;

class DataObjectsServiceProvider extends ServiceProvider
{
    public function register(): void
    {
        //
    }

    public function boot(): void
    {
        //
    }
}

This package does not need configuration yet, so the provider is intentionally empty.

You still want the provider because it gives the package a Laravel integration point. Later, you might publish config, register macros, bind a factory, or add commands. Starting with a provider makes the package ready for that growth.

Step 5: Write Package Tests

Create phpunit.xml:

<?xml version="1.0" encoding="UTF-8"?>
<phpunit bootstrap="vendor/autoload.php" colors="true">
    <testsuites>
        <testsuite name="Package Test Suite">
            <directory>tests</directory>
        </testsuite>
    </testsuites>
</phpunit>

Create tests/UserProfileDataTest.php:

<?php

namespace BuildWithAbdallah\DataObjects\Tests;

use BuildWithAbdallah\DataObjects\Data\UserProfileData;
use InvalidArgumentException;
use PHPUnit\Framework\TestCase;

class UserProfileDataTest extends TestCase
{
    public function test_it_builds_a_profile_dto_from_an_array(): void
    {
        $data = UserProfileData::fromArray([
            'name' => 'Abdallah',
            'email' => 'abdallah@example.com',
            'timezone' => 'America/New_York',
        ]);

        $this->assertSame('Abdallah', $data->name);
        $this->assertSame('abdallah@example.com', $data->email);
        $this->assertSame('America/New_York', $data->timezone);
    }

    public function test_it_uses_default_constructor_values(): void
    {
        $data = UserProfileData::fromArray([
            'name' => 'Abdallah',
            'email' => 'abdallah@example.com',
        ]);

        $this->assertSame('UTC', $data->timezone);
    }

    public function test_it_rejects_missing_required_fields(): void
    {
        $this->expectException(InvalidArgumentException::class);

        UserProfileData::fromArray([
            'name' => 'Abdallah',
        ]);
    }

    public function test_it_converts_to_array(): void
    {
        $data = new UserProfileData(
            name: 'Abdallah',
            email: 'abdallah@example.com',
            timezone: 'UTC',
        );

        $this->assertSame([
            'name' => 'Abdallah',
            'email' => 'abdallah@example.com',
            'timezone' => 'UTC',
        ], $data->toArray());
    }
}

Run the tests:

vendor/bin/phpunit

At this point the package works independently from a Laravel app. That is a good sign. Package code should be testable by itself where possible.

Step 6: Install the Package in a Laravel App

Go back to the workspace root:

cd ../../../..
composer create-project laravel/laravel demo-app
cd demo-app

Add the local package as a Composer path repository:

composer config repositories.data-objects path ../packages/buildwithabdallah/laravel-data-objects
composer require buildwithabdallah/laravel-data-objects:@dev

Check that Laravel discovered the provider:

php artisan package:discover

If Composer and Laravel are wired correctly, the package should load without errors.

Step 7: Use the DTO in a Controller

Create a request class:

php artisan make:request UpdateProfileRequest

Edit app/Http/Requests/UpdateProfileRequest.php:

<?php

namespace App\Http\Requests;

use Illuminate\Foundation\Http\FormRequest;

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

    public function rules(): array
    {
        return [
            'name' => ['required', 'string', 'max:120'],
            'email' => ['required', 'email', 'max:255'],
            'timezone' => ['sometimes', 'string', 'max:80'],
        ];
    }
}

Create a controller:

php artisan make:controller ProfileController

Edit app/Http/Controllers/ProfileController.php:

<?php

namespace App\Http\Controllers;

use App\Http\Requests\UpdateProfileRequest;
use BuildWithAbdallah\DataObjects\Data\UserProfileData;
use Illuminate\Http\JsonResponse;

class ProfileController extends Controller
{
    public function update(UpdateProfileRequest $request): JsonResponse
    {
        $profile = UserProfileData::fromArray($request->validated());

        // In a real app, pass $profile to a service class.
        // $this->profileService->update($request->user(), $profile);

        return response()->json([
            'message' => 'Profile DTO accepted.',
            'data' => $profile->toArray(),
        ]);
    }
}

Add a route in routes/web.php or routes/api.php:

use App\Http\Controllers\ProfileController;
use Illuminate\Support\Facades\Route;

Route::post('/profile', [ProfileController::class, 'update']);

Start the app:

php artisan serve

Test the endpoint:

curl -X POST http://127.0.0.1:8000/profile \
  -H "Content-Type: application/json" \
  -d '{"name":"Abdallah","email":"abdallah@example.com","timezone":"America/New_York"}'

Expected response:

{
  "message": "Profile DTO accepted.",
  "data": {
    "name": "Abdallah",
    "email": "abdallah@example.com",
    "timezone": "America/New_York"
  }
}

Now your Laravel app is using a local package as if it were installed from Packagist.

Step 8: Prepare the Package for Publishing

Before publishing the package publicly, add a license and README:

cd ../packages/buildwithabdallah/laravel-data-objects
touch LICENSE README.md

A basic README.md should include:

# Laravel Data Objects

Typed Data Transfer Objects for Laravel applications.

## Installation

composer require buildwithabdallah/laravel-data-objects

## Usage

$profile = UserProfileData::fromArray($request->validated());

Tag a release when you are ready:

git init
git add .
git commit -m "Initial Laravel data objects package"
git tag v0.1.0

Then push to GitHub and submit the repository to Packagist.

Common Errors and Fixes

Composer cannot find the package

Check the path repository:

composer config repositories.data-objects

From demo-app, the path should point to:

../packages/buildwithabdallah/laravel-data-objects

If the path is wrong, remove it and add it again:

composer config --unset repositories.data-objects
composer config repositories.data-objects path ../packages/buildwithabdallah/laravel-data-objects

Laravel does not discover the provider

Confirm the extra.laravel.providers block exists in the package composer.json, then run:

composer dump-autoload
php artisan package:discover

Class not found

This usually means the namespace does not match Composer's PSR-4 config.

The namespace:

namespace BuildWithAbdallah\DataObjects\Data;

Must match:

"BuildWithAbdallah\\DataObjects\\": "src/"

Then run:

composer dump-autoload

The DTO accepts unexpected fields

This simple implementation ignores extra array keys because it only reads constructor parameters. If you want to reject extra fields, compare the input keys against constructor parameter names inside fromArray().

Final Complete Example

Here is the full flow:

mkdir laravel-dto-package-demo
cd laravel-dto-package-demo

mkdir -p packages/buildwithabdallah/laravel-data-objects/src/Data
mkdir -p packages/buildwithabdallah/laravel-data-objects/src/Providers
mkdir -p packages/buildwithabdallah/laravel-data-objects/tests

cd packages/buildwithabdallah/laravel-data-objects
composer install
vendor/bin/phpunit

cd ../../../..
composer create-project laravel/laravel demo-app
cd demo-app
composer config repositories.data-objects path ../packages/buildwithabdallah/laravel-data-objects
composer require buildwithabdallah/laravel-data-objects:@dev
php artisan package:discover
php artisan serve

The package is small, but it demonstrates the core Laravel package workflow: Composer metadata, PSR-4 autoloading, service provider discovery, tests, and local installation into a real Laravel app.

Once that structure is clear, you can build packages for anything your apps repeat: API clients, billing helpers, admin components, validation rules, scheduled-job dashboards, or internal developer tooling.

Sources