PHPackages                             dragosstoenica/laravel-zod - PHPackages - PHPackages  [Skip to content](#main-content)[PHPackages](/)[Directory](/)[Categories](/categories)[Trending](/trending)[Leaderboard](/leaderboard)[Changelog](/changelog)[Analyze](/analyze)[Collections](/collections)[Log in](/login)[Sign up](/register)

1. [Directory](/)
2. /
3. [Validation &amp; Sanitization](/categories/validation)
4. /
5. dragosstoenica/laravel-zod

ActiveLibrary[Validation &amp; Sanitization](/categories/validation)

dragosstoenica/laravel-zod
==========================

Generate Zod v4 TypeScript schemas from Spatie Laravel Data classes (output) and Laravel FormRequest classes (input). Full Laravel rule coverage, schema references for nested Data classes, lazy-ref dedup for circular relations, locale-aware messages.

v0.1.0(4w ago)10MITPHPPHP ^8.3CI passing

Since May 11Pushed 4w agoCompare

[ Source](https://github.com/dragosstoenica/laravel-zod)[ Packagist](https://packagist.org/packages/dragosstoenica/laravel-zod)[ RSS](/packages/dragosstoenica-laravel-zod/feed)WikiDiscussions main Synced 1w ago

READMEChangelogDependencies (13)Versions (2)Used By (0)

dragosstoenica/laravel-zod
==========================

[](#dragosstoenicalaravel-zod)

[![Packagist Version](https://camo.githubusercontent.com/87adf824c5856b1502667b0baff5d2fc650bea78d3da87a76a4241ffc3bf0ca0/68747470733a2f2f696d672e736869656c64732e696f2f7061636b61676973742f762f647261676f7373746f656e6963612f6c61726176656c2d7a6f642e7376673f7374796c653d666c61742d737175617265)](https://packagist.org/packages/dragosstoenica/laravel-zod)[![PHP Version](https://camo.githubusercontent.com/d0d424178042615b0f883e8ff7ccd5a9756649b82aee093a8cbf0a214c1d16e4/68747470733a2f2f696d672e736869656c64732e696f2f7061636b61676973742f7068702d762f647261676f7373746f656e6963612f6c61726176656c2d7a6f642e7376673f7374796c653d666c61742d737175617265)](https://packagist.org/packages/dragosstoenica/laravel-zod)[![License](https://camo.githubusercontent.com/d3fcfaddd7a29e886a05f0774faca400f67692f1fb44fcc8c4e36f69b6faed76/68747470733a2f2f696d672e736869656c64732e696f2f6769746875622f6c6963656e73652f647261676f7373746f656e6963612f6c61726176656c2d7a6f642e7376673f7374796c653d666c61742d737175617265)](LICENSE)

Generate **Zod 4** schemas from Laravel `FormRequest` classes (input) and Spatie [`laravel-data`](https://spatie.be/docs/laravel-data) DTOs (output). PHP stays the source of truth; TypeScript gets runtime parsing **and** inferred types from a single file.

**Compatibility**: PHP 8.3 / 8.4 / 8.5 · Laravel 11 / 12 / 13 · Zod 4.x · Spatie Data 4.22+

```
// On a real server response — catches backend drift before it hits your render layer
const event = EventDataSchema.parse(await fetch('/api/events/1').then((r) => r.json()).then((j) => j.data));

// On form submit — reject before round-trip
const result = StoreEventRequestSchema.safeParse(formValues);
```

Why this exists
---------------

[](#why-this-exists)

Other generators have these problems:

PatternOther generatorsThis package`?UserData $host` on a Data classinlined `z.object({...})` (no schema reuse)`host: UserDataSchema.nullable()` ✓`EventAttendeeData[]` arrayinlined repeatedly`z.array(EventAttendeeDataSchema)` ✓Required string`z.string({error}).trim().refine(...).min(1)` (belt-and-suspenders)`z.string().trim().min(1, '...')` ✓Output schemadragged form-error messagestype narrowing + nullability only ✓Schema declaration orderfilenames or randomtopologically sorted ✓Circular refs (self / mutual)TDZ error at runtime`z.lazy(() => Schema)` on back-edges ✓Cross-field rules (`after:other`, `required_if`, …)partial / inlineone `.superRefine()` block at schema bottom ✓Localehardcoded English`--locale=ro` + Laravel lang file fallback chain ✓Install
-------

[](#install)

```
composer require dragosstoenica/laravel-zod
php artisan vendor:publish --tag=laravel-zod-config
```

The package's service provider auto-registers under Laravel's package discovery — no manual provider entry needed.

If you're consuming via a Composer path repository (recommended while iterating):

```
// composer.json
"repositories": [{ "type": "path", "url": "../packages/laravel-zod" }],
"require": { "dragosstoenica/laravel-zod": "@dev" }
```

Usage
-----

[](#usage)

### 1. Mark classes with `#[ZodSchema]`

[](#1-mark-classes-with-zodschema)

```
use LaravelZod\Attributes\ZodSchema;

// Output — inferred from PHP property types. No validation rules read.
#[ZodSchema]
class UserData extends \Spatie\LaravelData\Data
{
    public function __construct(
        public int $id,
        public string $name,
        public string $email,
    ) {}
}

#[ZodSchema]
class EventData extends \Spatie\LaravelData\Data
{
    public function __construct(
        public int $id,
        public string $title,
        public ?UserData $host,                                // → host: UserDataSchema.nullable()
        /** @var EventAttendeeData[]|null */
        public ?array $attendees,                              // → attendees: z.array(EventAttendeeDataSchema).nullable()
        public \Carbon\CarbonImmutable $starts_at,             // → starts_at: z.string()
    ) {}
}

// Input — Laravel rules() drive everything: type, constraints, cross-field, messages.
#[ZodSchema]
class StoreEventRequest extends \Illuminate\Foundation\Http\FormRequest
{
    public function rules(): array
    {
        return [
            'title' => ['required', 'string', 'max:160'],
            'starts_at' => ['required', 'date', 'after:now'],
            'ends_at' => ['required', 'date', 'after:starts_at'],
            'capacity' => ['nullable', 'integer', 'min:1', 'max:100000'],
        ];
    }

    public function messages(): array
    {
        return ['title.required' => 'Pick a name for your event.'];
    }
}
```

### 2. Generate

[](#2-generate)

```
php artisan zod:generate            # writes the configured output path
php artisan zod:generate --dry-run  # print to stdout
php artisan zod:generate --locale=ro # use lang/ro/validation.php for defaults
```

### 3. Consume from TypeScript

[](#3-consume-from-typescript)

```
import {
  EventDataSchema,           // .parse()-able output schema
  StoreEventRequestSchema,   // .parse()-able input schema
} from '@shared/schemas';
import type { z } from 'zod';

export type EventData = z.infer;            // { id; title; host; attendees; starts_at; … }
export type StoreEventRequest = z.infer;
```

The generated file exports `*Schema` constants and `*SchemaType` aliases (the latter is a `z.infer` for convenience).

Configuration
-------------

[](#configuration)

`config/laravel-zod.php`:

```
return [
    'output'  => base_path('../packages/shared-types/schemas.ts'),
    'scan'    => [app_path()],
    'locale'  => null,                            // null → app()->getLocale(), then 'en'
    'suffix'  => 'Schema',                        // ClassName + suffix → export const

    'server_only_rules'      => ['exists', 'unique', 'current_password'],
    'server_only_behaviour'  => 'comment',        // 'comment' | 'fail'
    'custom_rules_strict'    => false,            // true → fail when a custom Rule has no toZod()

    'header' => [
        '// AUTO-GENERATED by dragosstoenica/laravel-zod. Do not edit by hand.',
        '// Run `php artisan zod:generate` to refresh.',
    ],
];
```

Locales / messages
------------------

[](#locales--messages)

Resolution order, per (field, rule):

1. **`FormRequest::messages()` exact key** — `'title.required' => 'Pick a name.'`
2. **Wildcard** — `'*.required' => ':attribute is mandatory.'`
3. **Locale validation file** — `lang//validation.php` (`validation.required`)
4. **English fallback** — `lang/en/validation.php`
5. **Humanised fallback** — `Headline-cased validation failed.`

Placeholders filled: `:attribute` (from `attributes()` or the field name), `:min`, `:max`, `:size`, `:other`, `:value`, `:date`, `:format`, `:digits`, `:decimal`, `:values`.

For sub-keyed rules (`min`/`max`/`between`/`size`/`gt`/`gte`/`lt`/`lte`), the package picks the correct sub-key based on the field's inferred type:

```
'max' => [
    'string'  => 'The :attribute field must not be greater than :max characters.',
    'numeric' => 'The :attribute field must not be greater than :max.',
    'array'   => 'The :attribute field must not have more than :max items.',
    'file'    => 'The :attribute field must not be greater than :max kilobytes.',
],
```

To add a non-English locale:

```
php artisan lang:publish               # exposes Laravel's defaults under lang/
cp -r lang/en lang/ro                  # then translate lang/ro/validation.php
php artisan zod:generate --locale=ro
```

Custom Rule classes
-------------------

[](#custom-rule-classes)

Any value in `rules()` can be a Rule object. Two ways the package handles them:

### Opt-in: implement `HasZodSchema`

[](#opt-in-implement-haszodschema)

```
use LaravelZod\Contracts\HasZodSchema;
use Illuminate\Contracts\Validation\ValidationRule;

class StartsWithPlus implements ValidationRule, HasZodSchema
{
    public function validate(string $attribute, mixed $value, \Closure $fail): void
    {
        if (! str_starts_with($value, '+')) $fail('Must start with +.');
    }

    public function toZod(): string
    {
        // Either a chain fragment (starts with `.`) or a full expression.
        return ".refine((v) => typeof v === 'string' && v.startsWith('+'), 'Must start with +')";
    }
}

// Use it:
'phone' => ['required', new StartsWithPlus],
```

### Stringifiable rules (e.g. `Rule::in([...])`, `Rule::enum(MyEnum::class)`)

[](#stringifiable-rules-eg-rulein-ruleenummyenumclass)

The package recognises Laravel's built-in stringifiable Rule objects and re-runs them through the rule translator.

### Everything else

[](#everything-else)

A custom Rule object that implements neither `HasZodSchema` nor a recognised `__toString` is **skipped with a warning** and a `// custom rule skipped: …` comment in the schema. Set `'custom_rules_strict' => true` in the config to fail-loud instead.

Circular dependencies
---------------------

[](#circular-dependencies)

Self-references (`Comment.parent: ?Comment`) and mutual references (`Author.posts: Post[]` ↔ `Post.author: Author`) are detected during topological sort. The back-edge gets wrapped in `z.lazy(() => Schema)` automatically:

```
export const PostDataSchema = z.object({
  id: z.number().int(),
  title: z.string(),
  author: z.lazy(() => AuthorDataSchema),                 // ← back-edge
});

export const AuthorDataSchema = z.object({
  id: z.number().int(),
  name: z.string(),
  posts: z.array(PostDataSchema).nullable(),               // ← forward-edge, no lazy needed
});

export const CommentDataSchema = z.object({
  id: z.number().int(),
  body: z.string(),
  parent: z.lazy(() => CommentDataSchema).nullable(),      // ← self-reference
  replies: z.array(z.lazy(() => CommentDataSchema)).nullable(),
});
```

You don't need to do anything in PHP — annotate the relations as plain nullable types (`?UserData`, `?array` with `@var X[]`) and let the generator pick the right side to defer.

Rule coverage
-------------

[](#rule-coverage)

All rules are translated to client-side Zod where the semantics map. Cross-field rules become `.superRefine()` blocks. DB-backed rules emit a `// server-only` comment.

FamilyRulesPresence`required`, `nullable`, `sometimes`, `present`, `filled`, `bail`Conditional presence`required_if`, `required_unless`, `required_if_accepted`, `required_if_declined`, `required_with`, `required_with_all`, `required_without`, `required_without_all`, `required_array_keys`Missing / prohibited`missing`, `missing_if`, `missing_unless`, `missing_with`, `missing_with_all`, `prohibited`, `prohibited_if`, `prohibited_if_accepted`, `prohibited_unless`, `prohibits`Exclusion`exclude`, `exclude_if`, `exclude_unless`, `exclude_with`, `exclude_without`Accepted / declined`accepted`, `accepted_if`, `declined`, `declined_if`Types`string`, `integer`, `numeric`, `decimal`, `boolean`, `array`, `list`, `file`, `image`, `json`String constraints`alpha`, `alpha_dash`, `alpha_num`, `ascii`, `lowercase`, `uppercase`, `starts_with`, `doesnt_start_with`, `ends_with`, `doesnt_end_with`, `contains`, `doesnt_contain`, `hex_color`, `regex`, `not_regex`Sized`min`, `max`, `between`, `size` (polymorphic — string length / numeric value / array length)Numeric`gt`, `gte`, `lt`, `lte` (literal **or** field reference), `multiple_of`, `digits`, `digits_between`, `max_digits`, `min_digits`Dates`date`, `date_format`, `date_equals`, `after`, `after_or_equal`, `before`, `before_or_equal`, `timezone` (handles `now` / `today` / `tomorrow` / `yesterday` aliases and field references)Format`email`, `url`, `active_url`, `uuid`, `ulid`, `ip`, `ipv4`, `ipv6`, `mac_address`Membership`in`, `not_in`, `enum` (resolves backed PHP enums to `z.enum([...])`)Cross-field`same`, `different`, `confirmed`, `in_array`, `distinct`File`mimes`, `mimetypes`, `extensions`, `dimensions` (server-side image-dim check is deferred with a comment)Server-only`exists`, `unique`, `current_password` (skipped with comment)Anything else triggers an `Unhandled rule ''…` warning at generate time and is skipped.

Limitations
-----------

[](#limitations)

Honest list of what's not done:

- **Nested input shapes.** Dotted FormRequest rules like `items.*.qty` are skipped — the generated schema treats `items` as an unspecified array. To validate nested input shapes, point the field at a Data class and `request->validate()` server-side, then have the client construct the same Data via its own schema.
- **`active_url` DNS check** is server-only. The package emits `.url()` plus a `// active_url: DNS-resolution check is server-only` comment.
- **`dimensions` for images** needs an async `Image()` load to verify width/height. Currently emitted as a no-op refine with a server-side-only comment. Use `mimes`/`extensions` for client gating.
- **Locale fallbacks** only walk validation.php's exact key + en. Custom locale message overrides via `validation-inline.php` aren't read.
- **TypeScript inference for circular schemas** can fall back to `unknown` in deep cases. Zod 4 handles most cases via `z.lazy()`, but if you hit a stubborn one, declare the recursive type alias by hand.

Architecture
------------

[](#architecture)

```
src/
├── Attributes/ZodSchema.php                 # marker — `#[ZodSchema]`
├── Console/GenerateZodSchemasCommand.php    # `php artisan zod:generate`
├── Contracts/HasZodSchema.php               # opt-in for custom Rule classes
├── Discovery/ClassDiscoverer.php            # scan paths for the attribute
├── Analyzers/
│   ├── DataClassAnalyzer.php                # PHP types → PropertySchema
│   └── FormRequestAnalyzer.php              # rules() + messages() → translator pipeline
├── Schema/
│   ├── PropertyType.php                     # STRING|NUMBER|INTEGER|BOOLEAN|ARRAY|OBJECT|FILE|DATE|ENUM|REF|ANY
│   ├── Constraint.php                       # one Zod chain link
│   ├── CrossFieldRefine.php                 # one `.superRefine` body
│   ├── PropertySchema.php                   # name, type, constraints[], rawSuffixes[], nullable, optional, useLazyReference, …
│   ├── ObjectSchema.php                     # exportName, sourceClass, properties[], crossFieldRefines[]
│   └── SchemaRegistry.php                   # FQN → "UserDataSchema"
├── Translation/
│   ├── MessageResolver.php                  # custom + wildcard + lang/.validation + en + headline fallback
│   └── RuleTranslator.php                   # one method per Laravel rule
├── Rendering/ZodRenderer.php                # ObjectSchema[] → schemas.ts string
└── ZodSchemasServiceProvider.php

```

Two-pass generation:

1. **Discovery pass** — walk `scan` paths, find every `#[ZodSchema]`-attributed class, register ` → ` in the `SchemaRegistry`.
2. **Render pass** — analyze each class (Data → reflection of typed props; FormRequest → `rules()` array fed through `RuleTranslator`), build an `ObjectSchema`, topologically sort with cycle detection, mark back-edges with `useLazyReference`, render.

Contributing
------------

[](#contributing)

Bug reports and PRs welcome. Things that would be useful next:

- Nested FormRequest input schemas (`items.*.qty`)
- A way to attach a hand-written `superRefine` to a Data class (cross-field on outputs)
- Pluggable writers (Yup, Valibot, ArkType, …) — the renderer is the only thing that changes

License
-------

[](#license)

MIT.

###  Health Score

36

—

LowBetter than 79% of packages

Maintenance94

Actively maintained with recent releases

Popularity2

Limited adoption so far

Community6

Small or concentrated contributor base

Maturity38

Early-stage or recently created project

 Bus Factor1

Top contributor holds 100% of commits — single point of failure

How is this calculated?**Maintenance (25%)** — Last commit recency, latest release date, and issue-to-star ratio. Uses a 2-year decay window.

**Popularity (30%)** — Total and monthly downloads, GitHub stars, and forks. Logarithmic scaling prevents top-heavy scores.

**Community (15%)** — Contributors, dependents, forks, watchers, and maintainers. Measures real ecosystem engagement.

**Maturity (30%)** — Project age, version count, PHP version support, and release stability.

###  Release Activity

Cadence

Unknown

Total

1

Last Release

29d ago

### Community

Maintainers

![](https://www.gravatar.com/avatar/3647a630104a9b02ec04e954e323dcf0fc6fcddc7460e43490d4e5278a4823d8?d=identicon)[dragosstoenica](/maintainers/dragosstoenica)

---

Top Contributors

[![dragosstoenica](https://avatars.githubusercontent.com/u/37094?v=4)](https://github.com/dragosstoenica "dragosstoenica (5 commits)")

---

Tags

laravelschemavalidationtypescriptcodegenzodform-requesttype-safetyspatie-data

###  Code Quality

TestsPest

Static AnalysisPHPStan, Rector

Code StyleLaravel Pint

### Embed Badge

![Health badge](/badges/dragosstoenica-laravel-zod/health.svg)

```
[![Health](https://phpackages.com/badges/dragosstoenica-laravel-zod/health.svg)](https://phpackages.com/packages/dragosstoenica-laravel-zod)
```

###  Alternatives

[laravel/mcp

Rapidly build MCP servers for your Laravel applications.

76318.2M110](/packages/laravel-mcp)[propaganistas/laravel-disposable-email

Disposable email validator

6012.9M7](/packages/propaganistas-laravel-disposable-email)[laravel/ai

The official AI SDK for Laravel.

9782.1M153](/packages/laravel-ai)[laravel/sail

Docker files for running a basic Laravel application.

1.9k199.2M1.2k](/packages/laravel-sail)[proengsoft/laravel-jsvalidation

Validate forms transparently with Javascript reusing your Laravel Validation Rules, Messages, and FormRequest

1.1k2.3M50](/packages/proengsoft-laravel-jsvalidation)[wendelladriel/laravel-validated-dto

Data Transfer Objects with validation for Laravel applications

762621.7k17](/packages/wendelladriel-laravel-validated-dto)

PHPackages © 2026

[Directory](/)[Categories](/categories)[Trending](/trending)[Changelog](/changelog)[Analyze](/analyze)
