waleedhu / rules-to-schema
Build JSON Schema from Laravel validation rules.
Requires
- php: ^8.3
- illuminate/json-schema: ^13.0
Requires (Dev)
- laravel/pint: ^1.18
- phpunit/phpunit: ^11.0 || ^12.0 || ^13.0
README
Build JSON Schema from Laravel validation rules.
You already describe the shape of your data once, in validation rules. This turns that description into a JSON Schema, so you don't write it a second time to define an LLM tool, a structured output format, or an OpenAPI body.
composer require waleedhu/rules-to-schema
Requires PHP 8.3+ and Laravel 13's illuminate/json-schema.
Usage
use WaleedHu\RulesToSchema\RuleSchema; $schema = RuleSchema::fromRules([ 'title' => 'required|string|min:3|max:120', 'status' => 'required|in:draft,published,archived', 'author.name' => 'required|string|max:50', 'author.email' => 'required|email', 'rating' => 'nullable|numeric|between:0,5|multiple_of:0.5', 'tags' => 'array|max:5', 'tags.*' => 'string|max:20', ]);
$schema is an Illuminate\JsonSchema\Types\ObjectType, so toArray() gives you a PHP array and casting to string gives you JSON:
{
"properties": {
"title": { "minLength": 3, "maxLength": 120, "type": "string" },
"status": { "enum": ["draft", "published", "archived"], "type": "string" },
"author": {
"properties": {
"name": { "maxLength": 50, "type": "string" },
"email": { "format": "email", "type": "string" }
},
"type": "object",
"required": ["name", "email"]
},
"rating": { "minimum": 0, "maximum": 5, "multipleOf": 0.5, "type": ["number", "null"] },
"tags": { "maxItems": 5, "items": { "maxLength": 20, "type": "string" }, "type": "array" }
},
"type": "object",
"required": ["title", "status"]
}
Note that author itself is optional here, because no rule marks it required — but name and email are required within it if it is present. Add 'author' => 'required' to require the object too.
Reusing a form request
Rules live in a form request more often than in an array, and they work just as well:
RuleSchema::fromRules((new StorePostRequest)->rules());
Nesting and arrays
Dot notation becomes nested objects, and * becomes array items:
RuleSchema::fromRules([ 'attachments' => 'array', 'attachments.*.url' => 'required|url', 'attachments.*.size' => 'integer|min:1', ]);
{
"properties": {
"attachments": {
"items": {
"properties": {
"url": { "format": "uri", "type": "string" },
"size": { "minimum": 1, "type": "integer" }
},
"type": "object",
"required": ["url"]
},
"type": "array"
}
},
"type": "object"
}
What gets mapped
Only rules with an exact JSON Schema counterpart.
| Rules | Becomes |
|---|---|
string, integer, numeric, boolean, array, list, decimal |
type |
required |
the parent's required list |
nullable |
"null" added to type |
min, max, size, between |
minLength/maxLength, minimum/maximum, or minItems/maxItems |
multiple_of |
multipleOf |
in |
enum |
regex |
pattern |
email, url, uuid, ipv4, ipv6, date |
format |
Bounds are resolved in the unit the type is measured in, the same way the validator sizes a value: min:3 is minLength on a string, minimum on a number, and minItems on an array. A field with no type rule is treated as a string, which is also how the validator sizes an untyped value.
What gets ignored
Anything without an exact counterpart is skipped rather than approximated, so the schema never claims a constraint the rules don't express:
- Rules with no equivalent, such as
confirmed,existsandmimes. ip, which accepts either version, so it matches neither theipv4nor theipv6format.ulid, because a ULID is not a UUID.- Rule objects, closures and invokable rules, whose logic isn't inspectable.
Testing
composer test
License
MIT.