Kita sering memilih antara dua pilihan yang sama-sama tidak sempurna saat bekerja dengan Data Transfer Objects di PHP: pakai plain PHP array yang cepat tapi tidak ada type safety, atau pakai library seperti spatie/laravel-data yang nyaman tapi membawa overhead reflection di setiap instantiasi. Pada sistem yang menangani ribuan request per detik, pilihan kedua bisa terasa mahal.
Masalah utamanya ada pada reflection. Library DTO berbasis reflection seperti spatie/laravel-data dan cuyz/valinor melakukan introspeksi class metadata setiap kali objek dibuat — membaca anotasi, memetakan properti, menjalankan transformasi. Ini berguna untuk developer experience, tapi ada harga yang harus dibayar setiap kali new UserDto($data) dipanggil. Di aplikasi yang menginisiasi ribuan DTO per request, biaya itu tidak lagi bisa diabaikan.
php-collective/dto mengambil pendekatan berbeda: alih-alih melakukan introspeksi saat runtime, library ini membangkitkan plain PHP class saat build time. Hasilnya adalah kode yang bisa kita baca, review di pull request, dan langsung dipahami IDE tanpa plugin tambahan — dengan performa yang mendekati kode yang ditulis tangan.
Kenapa Reflection Mahal dan Kapan Itu Masalah
Reflection API di PHP memang powerful, tapi setiap panggilan ke ReflectionClass atau ReflectionProperty memiliki cost. Bayangkan sebuah endpoint yang mengembalikan daftar 500 produk, dan setiap produk dikonversi ke DTO lengkap dengan nested address dan list of tags — itu berarti ribuan instantiasi DTO dalam satu request.
Benchmark dengan 10.000 iterasi menggunakan PHP 8.4 menunjukkan selisih yang cukup signifikan:
| Skenario | php-collective/dto | spatie/laravel-data | Perbedaan |
|---|---|---|---|
| Simple DTO creation | 0.60 µs | 14.77 µs | ~25x lebih cepat |
| Complex nested DTOs | 3.10 µs | 48.83 µs | ~16x lebih cepat |
| Serialization | 1.20 µs | 26.95 µs | ~22x lebih cepat |
Plain PHP readonly class memang masih sekitar 2x lebih cepat, tapi generated DTO mendapat fitur lengkap dengan performa yang mendekati native.
Perlu dicatat: untuk aplikasi dengan volume DTO rendah, perbedaan ini sering tidak terasa. Tapi kalau sistem kita memproses ribuan objek per request atau menjalankan batch job besar, angka-angka di atas menjadi relevan.
Cara Kerja: Define, Generate, Pakai
Pendekatan library ini sederhana: kita mendefinisikan struktur DTO dalam satu format konfigurasi, jalankan satu perintah generate, dan PHP class siap digunakan sudah tersedia di direktori output.
Install library via Composer:
composer require php-collective/dto
Mendefinisikan DTO dengan PHP Builder
Format paling fleksibel adalah PHP builder, karena IDE bisa memberikan autocomplete langsung saat kita mengetik konfigurasinya.
Buat file definisi di config/dto.php:
// config/dto.php
use PhpCollective\Dto\Builder\Dto;
use PhpCollective\Dto\Builder\Field;
return [
Dto::create('UserDto')
->namespace('App\\Dto')
->fields(
Field::int('id')->required(),
Field::string('name')->required(),
Field::string('email')->required(),
Field::string('bio')->nullable(),
Field::dto('address', 'AddressDto')->nullable(),
),
Dto::create('AddressDto')
->namespace('App\\Dto')
->fields(
Field::string('street')->required(),
Field::string('city')->required(),
Field::string('country')->required(),
Field::string('postalCode')->nullable(),
),
];
Setiap Field merepresentasikan satu properti beserta tipenya. Method required() dan nullable() mengontrol apakah field harus ada saat instantiasi.
Generate PHP Class
Jalankan perintah generate untuk menghasilkan class PHP:
vendor/bin/dto generate
Perintah ini menghasilkan file PHP class di direktori output yang sudah dikonfigurasi. Kode yang dihasilkan adalah plain PHP — tidak ada magic, tidak ada proxy, tidak ada runtime introspeksi.
Generated: src/Dto/UserDto.php
Generated: src/Dto/AddressDto.php
File yang dihasilkan bisa langsung di-commit ke repository sehingga semua anggota tim bisa melihat perubahan struktural DTO di pull request seperti perubahan kode biasa.
Menggunakan DTO yang Dihasilkan
Setelah class tersedia, kita menggunakannya seperti PHP class biasa:
use App\Dto\UserDto;
use App\Dto\AddressDto;
$address = new AddressDto();
$address->setStreet('Jl. Sudirman No. 10');
$address->setCity('Jakarta');
$address->setCountry('ID');
$user = new UserDto();
$user->setId(1);
$user->setName('Budi Santoso');
$user->setEmail('budi@example.com');
$user->setAddress($address);
// Konversi ke array untuk response API
return $user->toArray();
IDE langsung mengenali semua method karena method-method tersebut benar-benar ada dalam file PHP yang dihasilkan, bukan ditangani via __call() atau magic method lainnya.
Format Konfigurasi YAML untuk Sintaks Lebih Ringkas
Jika lebih suka format deklaratif, YAML juga didukung:
# config/dto/user.yaml
UserDto:
namespace: App\Dto
fields:
id:
type: int
required: true
name:
type: string
required: true
email:
type: string
required: true
bio:
type: string
nullable: true
Format XML juga tersedia dengan dukungan XSD validation untuk IDE autocomplete yang lebih ketat.
Fitur yang Berguna di Situasi Nyata
Immutable DTO untuk Event Sourcing
Kita bisa mendefinisikan DTO sebagai immutable, di mana setiap modifikasi mengembalikan instance baru:
// Definisi dengan immutable mode
Dto::create('OrderDto')
->immutable()
->fields(
Field::int('id')->required(),
Field::string('status')->required(),
Field::float('total')->required(),
),
Penggunaan setelah generate:
$order = new OrderDto();
$order->setId(42)->setStatus('pending')->setTotal(150000.0);
// withStatus() mengembalikan instance baru, tidak mengubah yang asli
$paidOrder = $order->withStatus('paid');
echo $order->getStatus(); // pending
echo $paidOrder->getStatus(); // paid
Pola ini berguna untuk event sourcing dan functional pipelines di mana mutasi tidak terduga bisa menyebabkan bug yang sulit dilacak.
Field Tracking untuk Partial Update
Saat menangani form update, seringkali kita hanya ingin menyimpan field yang benar-benar diubah user, bukan semua field:
$dto = new UserDto();
$dto->fromArray($request->all(), false, UserDto::TYPE_UNDERSCORED);
// Hanya field yang disentuh yang masuk ke database
$repository->update($userId, $dto->touchedToArray());
touchedToArray() mengembalikan array yang hanya berisi field yang benar-benar di-set sejak instantiasi, sehingga UPDATE query tidak menulis ulang kolom yang tidak diubah.
Collections Bertipe
Untuk relasi one-to-many, library ini menghasilkan typed collection:
// Definisi
Dto::create('OrderDto')
->fields(
Field::int('id')->required(),
Field::dtoCollection('items', 'OrderItemDto'),
),
Class yang dihasilkan memiliki method collection yang type-safe:
$order->addItem($item); // hanya menerima OrderItemDto
$order->getItems(); // mengembalikan ArrayObject<OrderItemDto>
$order->hasItems(); // boolean check
$order->removeItem($index); // hapus berdasarkan index
Tanpa ini, kita biasanya menggunakan array biasa yang tidak ada garansi isinya.
OrFail untuk Nullable Field
Setiap field yang nullable secara otomatis mendapat method OrFail:
$user->getBio(); // mengembalikan ?string (bisa null)
$user->getBioOrFail(); // mengembalikan string, throw jika null
Ini membantu static analysis tools seperti PHPStan dan Psalm bekerja lebih akurat — kita menyatakan secara eksplisit bahwa di titik ini nilai tidak boleh null.
Strategi Adopsi Bertahap
Tidak perlu mengkonversi seluruh codebase sekaligus. Cara yang paling efektif adalah memulai dari titik paling menyakitkan:
Mulai dengan response boundary API — konversi array yang dikembalikan controller menjadi DTO. Di sinilah kontrak paling sering berubah dan type safety paling berharga. Kemudian geser ke signature method service layer, lalu ke internal data flow antar komponen. Untuk aplikasi Laravel, tersedia package php-collective/laravel-dto yang menangani integrasi framework.
Hal yang Perlu Dipertimbangkan
- Build step adalah trade-off. Setiap kali mengubah definisi DTO, perlu menjalankan ulang
vendor/bin/dto generate. Ini harus masuk ke CI pipeline dan onboarding documentation. - Generated files di-commit atau tidak? Keduanya valid. Commit ke repo memudahkan review di PR; tidak commit berarti generate selalu segar tapi perlu diingat di setiap setup environment.
- Framework-specific alternatives tetap kompetitif. Jika proyek sudah full Laravel dan tidak ada masalah performa aktual,
spatie/laravel-datatetap pilihan yang solid karena integrasi dengan request validation, Eloquent, dan queue-nya sangat matang. - Kompatibilitas: Library ini bekerja dengan PHP 8.1+ dan mendukung PHP 8.3/8.4.
Kesimpulan
php-collective/dto membuktikan bahwa kita tidak harus memilih antara performa dan developer experience. Dengan menggeser pekerjaan introspeksi dari runtime ke build time, kita mendapat kode yang transparan, reviewable, dan cepat — sekaligus tetap memiliki type safety, IDE support, dan fitur-fitur praktis seperti immutability, field tracking, dan typed collections. Untuk sistem yang mulai merasakan bottleneck dari reflection overhead, ini bisa menjadi solusi yang worth exploring.