rogerdsh/liform
Liform is a lightweight form-building package for Laravel. Define forms in PHP, render them in Blade, and handle validation with minimal boilerplate—keeping markup consistent and controllers clean. A simple fit for admin panels and small apps.
First, a caveat you should act on before writing any code: this package has 0 stars, 0 dependents, and no published documentation. The name, namespace (Liform\) and description strongly suggest it is a re-publish/fork of limenius/liform, the long-standing "Symfony Forms → JSON Schema" library. Treat the README (if any) as aspirational and the source as the spec. Before adopting, diff it against upstream limenius/liform — you may be better off requiring the upstream package directly, or vendoring this one at a pinned commit.
composer require rogerdsh/liform
If Packagist has no usable release (likely), pin the repo instead:
{
"repositories": [
{ "type": "vcs", "url": "https://github.com/RogerDS/Liform" }
],
"require": {
"rogerdsh/liform": "dev-master#<full-commit-sha>"
}
}
Never float on dev-master for this one — there is no maintainer signal and no test/dependents safety net.
src/Resolver.php — the public entrypoint. Everything hangs off it: setForm(), setDefaultTransformers(), addTransformer(), transform().src/Transformer/FormTransformer.php — how type: object, properties, and required are produced.src/Transformer/ConstraintTransformer.php (or similarly named) — the Symfony Validator constraint → JSON Schema keyword mapping. This is where 80% of your surprises will live (NotBlank → minLength, Regex → pattern, etc.).tests/ — golden-schema fixtures are the real documentation for this class of library.composer.json — check the symfony/form constraint. If it's ^3.4|^4.0 and you're on Symfony 6/7, expect breakage from FormConfigBuilder/getBlockPrefix changes.use Liform\Resolver;
// Build the form the same way your controller does. Use null data —
// the schema describes the shape, not the values.
$form = $formFactory->create(ProductType::class, null, [
'csrf_protection' => false,
]);
$resolver = new Resolver();
$resolver->setForm($form);
$resolver->setDefaultTransformers();
$schema = $resolver->transform();
return new JsonResponse($schema);
If a bundle/service definition is shipped, inject the resolver rather than new-ing it:
public function schema(Resolver $resolver, FormFactoryInterface $forms): JsonResponse
{
$resolver->setForm($forms->create(ProductType::class));
$resolver->setDefaultTransformers();
return $this->json($resolver->transform());
}
A single GET /api/schemas/product endpoint feeding a client-side form renderer (react-jsonschema-form, @rjsf/core, or ajv for pre-submit validation). Keep it narrow: one form type, flat fields, no collections, no entity types. That exercises the whole pipeline (form build → transformer → constraint mapping) with the least surface area, and tells you within an hour whether the package is viable for you.
Do not scatter new Resolver() through controllers. Wrap it once so you have one place to patch when you inevitably fork a transformer:
final class SchemaGenerator
{
public function __construct(private FormFactoryInterface $forms) {}
public function forType(string $formType, array $options = []): array
{
$form = $this->forms->create($formType, null, $options + [
'csrf_protection' => false,
]);
$resolver = new Resolver();
$resolver->setForm($form);
$resolver->setDefaultTransformers();
return $resolver->transform();
}
}
A schema depends only on the form type class + options, not on the request. It is a pure function output, and it is slow (it builds a full form tree and runs every transformer, including constraint reflection). Cache it:
$schema = $this->cache->get($key = 'schema.'.$formType, function () use ($formType) {
return $this->schemaGenerator->forType($formType);
});
Invalidate on deploy (cache:clear in CI) or key on a build hash. Do not key on user input — the schema doesn't vary by user, which is also why you should be careful what you put in it (see Gotchas).
The reason to use this over hand-writing JSON Schema is that your Symfony constraints become the client-side contract. Lean into it:
$builder->add('email', EmailType::class)
->add('age', IntegerType::class, [
'constraints' => [new Assert\Range(['min' => 18, 'max' => 120])],
])
->add('sku', TextType::class, [
'constraints' => [new Assert\Regex(['pattern' => '/^[A-Z]{3}-\d{4}$/'])],
]);
→ format: email, minimum/maximum, pattern. Write an integration test asserting those keywords appear, because the mapping table is the part most likely to be incomplete in a low-traffic fork.
Symfony's EntityType/ChoiceType with a query builder and FileType will not produce meaningful JSON Schema on their own. Register a transformer that intercepts your form type and emits a scalar:
$resolver->setForm($form);
$resolver->setDefaultTransformers();
$resolver->addTransformer(new CategoryTransformer()); // your class, implements the package's transformer interface
Look at how the package's own FieldTransformer implements the interface and mirror that signature exactly — the constructor/transform signature is the integration point, and it varies between upstream versions.
For CollectionType, the output should be {"type": "array", "items": {...}}. Verify it does that rather than emitting properties: ["0", "1", ...] from a built prototype. If it's wrong, the fix is usually in FormTransformer recursing into $child->getConfig()->getType()->getInnerType().
Because there's no upstream you can rely on to not break you, put a golden-file test around every form type you expose:
public function testProductSchemaUnchanged(): void
{
$schema = $this->generator->forType(ProductType::class);
$this->assertJsonStringEqualsJsonFile(__DIR__.'/fixtures/product.json', json_encode($schema));
}
This turns any accidental form-type edit (someone adds a field) into a deliberate, reviewable schema diff.
Decide early whether clients consume the schema only for rendering or also for validation. If validation, you need the dialect to match your validator (draft-07 for most ajv setups; older limenius emits draft-04). Pin ajv to the matching meta-schema or you'll get "unknown keyword" noise.
Zero-traffic dependency risk is the headline gotcha. 0 stars / 0 dependents / score 0 means: no issue tracker activity, no security response, no Symfony-version upkeep. Plan for one of these:
app/src/Liform (or a private VCS) and own it. It's a few thousand lines and mostly pure functions — this is genuinely the low-risk path.limenius/liform upstream, which at least has history and existing forks you can search for fixes.required will not mean what you think it means. Symfony's required option is a rendering hint (HTML5 required attribute), not a validation rule. NotBlank/NotNull constraints are the real thing. A schema transformer that reads the required option will emit a JSON Schema that rejects "" on the client while the server accepts it (or vice versa). Test with an empty-string payload specifically.
data_class and data transformers leak. If you pass an entity as form data, or a model transformer is registered, transformation can throw or produce garbage. Always call setForm with a form built against null data. If a transformer forces a value, you'll see it as a default in the schema — sometimes useful, often a stale snapshot of a database row in an API response.
ChoiceType with a lazy/dynamic choices callback freezes the enum at schema-build time. If choices come from a DB, your cached schema becomes wrong the moment a category is added. Either bust the cache on writes, or override that field with an enum sourced at request time and keep the static schema free of enum.
File and entity fields need suppression. FileType has no JSON Schema representation and EntityType will try to resolve a class to a schema. Override those to {"type": "string", "format": "binary"} and {"type": "integer"} respectively, or exclude them from the form passed to the resolver.
Symfony version drift is your most likely failure mode. The package likely targets Symfony 3.4/4.x Form internals. On Symfony 6/7 watch for: return types on buildForm, getBlockPrefix removal, FormConfigInterface::getType() returning ResolvedFormTypeInterface with a deprecated getInnerType(). Errors surface as fatal TypeErrors deep inside a transformer, not as friendly exceptions.
Debugging is by var_dump, not stack traces. Transformer exceptions tend to lose context. Wrap and dump the form tree first, then the transform:
try {
$schema = $resolver->transform();
} catch (\Throwable $e) {
dump($form->all()); // what children actually exist
dump(get_class($e), $e->getMessage());
throw $e;
}
Transformer ordering matters. setDefaultTransformers() establishes the chain; if you addTransformer() before it, yours may be clobbered, and vice versa it may shadow built-ins globally. Add custom transformers after defaults and scope them tightly (match on a specific form type, not a generic FormType).
Extension points to lean on. The resolver's transformer registry and each transformer's per-field output are your two levers. You almost never need to touch FormTransformer's object/properties assembly — you need to intercept at the field level. That keeps your patch small when you next upgrade.
Don't expose more than you intend. The schema is built from the same form used for writes. If that form has admin-only or internal fields, the schema endpoint leaks their existence, labels, and constraints. Either maintain a separate *SchemaType per endpoint or strip fields from the array before returning it — the latter is brittle, the former is the pattern worth copying.
Internationalization surprises. Labels and title/description are pulled via the translator at build time. If your schema is cached, users switching locale get the wrong-language schema. Cache per-locale, or strip titles from the response and let the client localize.
How can I help you explore Laravel packages today?