REST API for defining dynamic forms, publishing active versions, and accepting submissions. The codebase is organized around Domain-Driven Design (DDD) with clear separation between domain rules, application workflows, infrastructure (persistence, cache, logging), and HTTP delivery.
- PHP ^8.1
- Laravel 10
- MySQL (see
database/migrations) - Composer
Configure .env (copy from .env.example). For asynchronous submission processing and realistic caching in production, set QUEUE_CONNECTION to database or redis and run a queue worker; set CACHE_DRIVER to redis or another shared store if you run multiple app instances.
flowchart TB
subgraph presentation [Presentation - HTTP]
Routes[routes/api.php]
Controllers[Http/Controllers]
end
subgraph application [Application layer]
Commands[Commands]
Handlers[Handlers]
Pipes[Pipes - Submission pipeline]
Jobs[Jobs - Queue]
Audit[Common/Audit]
end
subgraph domain [Domain layer]
Entities[Entities]
VOs[Value Objects]
ReposIf[Repository interfaces]
DomainEx[Domain exceptions]
FieldTypes[Field types + Factory]
end
subgraph infrastructure [Infrastructure]
Eloquent[Persistence/Repositories]
Models[Persistence/Models]
CacheSvc[Cache/FormCacheService]
AuditLog[Logging/DatabaseAuditLogger]
Providers[Providers/DomainServiceProvider]
end
Routes --> Controllers
Controllers --> Handlers
Controllers --> ReposIf
Handlers --> Commands
Handlers --> ReposIf
Handlers --> Pipes
Handlers --> Jobs
Handlers --> Audit
Handlers --> CacheSvc
Pipes --> ReposIf
Pipes --> FieldTypes
Jobs --> ReposIf
Audit --> AuditLog
Providers -.bind.- ReposIf
Providers -.to.- Eloquent
Eloquent --> Models
Entities --- ReposIf
Dependency rule: outer layers depend inward. The Domain layer has no Laravel imports; Application orchestrates use cases; Infrastructure implements technical details; HTTP is a thin adapter that validates input and maps responses.
| Layer | Path (PSR-4) | Responsibility |
|---|---|---|
| Domain | Domain\ → app/Domain/ |
Entities, value objects, domain exceptions, repository contracts, field-type contracts and strategies. Expresses business language and invariants. |
| Application | Application\ → app/Application/ |
Commands, handlers, submission pipeline steps, queued jobs, cross-cutting application services (e.g. audit). No HTTP; coordinates domain + ports. |
| Infrastructure | Infrastructure\ → app/Infrastructure/ |
Eloquent repositories, Eloquent models, cache helpers, database audit logger, service provider bindings. |
| Presentation | App\Http\ |
Controllers, form requests, JSON resources. Translates HTTP ↔ application commands and repository calls. |
Composer maps these roots explicitly (composer.json autoload.psr-4) so namespaces stay aligned with DDD boundaries.
Relevant application code lives under app/. Standard Laravel folders (bootstrap/, config/, public/, resources/, routes/, storage/, tests/, vendor/) follow the framework defaults.
app/
├── Application/ # Use cases (orchestration, no HTTP)
│ ├── Common/Audit/ # AuditLogService
│ ├── Form/
│ │ ├── Commands/
│ │ └── Handlers/
│ └── Submission/
│ ├── Commands/
│ ├── Handlers/
│ ├── Jobs/ # ProcessSubmissionJob (queue)
│ └── Pipes/ # Submission pipeline steps
├── Domain/ # Business model (framework-agnostic)
│ ├── Field/
│ │ ├── FieldTypeRegistry.php # Resolves field type → strategy (namespace Domain\Field)
│ │ ├── Contracts/ # FieldTypeInterface
│ │ └── Types/ # TextField, SelectField, …
│ ├── Form/
│ │ ├── Entities/, Events/, Exceptions/
│ │ ├── Repositories/ # FormRepositoryInterface
│ │ └── ValueObjects/
│ └── Submission/
│ ├── Entities/, Exceptions/, Repositories/, ValueObjects/
├── Infrastructure/ # Adapters: DB, cache, logging
│ ├── Cache/ # FormCacheService
│ ├── Logging/ # DatabaseAuditLogger
│ ├── Persistence/
│ │ ├── Models/ # Eloquent models + traits
│ │ └── Repositories/ # Eloquent*Repository implementations
│ └── Providers/ # DomainServiceProvider
├── Http/
│ ├── Controllers/ # Public + Admin + Auth
│ ├── Middleware/, Requests/, Resources/
├── Models/ # Laravel User (auth)
├── Providers/ # App, Route, …
└── Exceptions/ # Global Handler
database/
├── factories/ # UserFactory (default password: "password")
├── migrations/ # forms, form_fields, form_versions, …
└── seeders/
├── DatabaseSeeder.php # Calls UserSeeder, FormSeeder
├── UserSeeder.php
└── FormSeeder.php
- Form management: lifecycle of forms, fields, schema versioning, status (draft / active / archived), and published schema snapshots (
FormVersion). - Submission intake: validate and transform answers against the active schema, persist a submission, then hand off async post-processing (e.g. notifications, integrations).
These map to Domain\Form, Domain\Submission, and matching Application\Form / Application\Submission modules.
Domain defines FormRepositoryInterface and SubmissionRepositoryInterface. Implementations live in Infrastructure\Persistence\Repositories and are bound in App\Infrastructure\Providers\DomainServiceProvider. Controllers and handlers depend on interfaces, so persistence can be swapped or mocked without touching domain rules.
Each use case is expressed as a small command object (e.g. CreateFormCommand, SubmitFormCommand) handled by a dedicated handler (e.g. CreateFormHandler, SubmitFormHandler). This keeps controllers slim and makes behavior easy to locate and test.
SubmitFormHandler runs SubmitFormCommand through Laravel’s Pipeline with ordered pipes:
- EvaluateConditionsPipe — conditional show/hide logic
- ValidateDataPipe — dynamic validation using field types
- TransformDataPipe — normalize submitted values
- SaveSubmissionPipe — persist within the same DB transaction
After the pipeline completes, ProcessSubmissionJob is dispatched for asynchronous work.
Domain\Field\Contracts\FieldTypeInterface is implemented per type (TextField, NumberField, SelectField, etc.). Domain\Field\FieldTypeRegistry resolves the correct strategy from the stored type string. Validation and transformation stay extensible without a large switch scattered through the codebase.
Types such as FormStatus, FieldType, SchemaVersion, ValidationRules, ConditionalLogic, and SubmissionStatus wrap primitives and enforce valid combinations, reducing invalid states in entities.
Domain\Form\Entities\Form records events like FormUpdated / FormDeleted and exposes pullDomainEvents(). This models something happened in the domain separately from Laravel’s event bus. Wiring pullDomainEvents() to application or infrastructure dispatchers is a natural next step if you need cross-service reactions.
Application\Common\Audit\AuditLogService sits in the application layer and delegates to Infrastructure\Logging\DatabaseAuditLogger, so auditing is a port with a concrete adapter rather than domain logic leaking into raw DB calls from controllers.
Infrastructure\Cache\FormCacheService wraps Laravel’s cache repository with a fixed TTL (1 hour) and key conventions:
form:schema:{id}— single form resolution (adminshow, publicshowActive)form:active:list— list of active forms for public listing
Invalidation is triggered from write paths after mutations: form update/delete and field add/update/delete handlers call invalidate($formId), which clears both the per-form key and the active list key so readers do not serve stale schemas.
Reads go through remember / rememberList; writes own consistency via invalidation (cache-aside pattern).
Application\Submission\Jobs\ProcessSubmissionJob implements ShouldQueue, receives a submissionId, loads the aggregate via SubmissionRepositoryInterface, and transitions status to processed or failed with logging and retries ($tries = 3). The HTTP submit endpoint responds 202 after persistence and dispatch, decoupling the client from slow side effects.
With QUEUE_CONNECTION=sync in .env.example, jobs run inline during the request (useful locally). Use a real queue driver and php artisan queue:work in production.
- Domain / application-specific:
Domain\Submission\Exceptions\SubmissionExceptioncarries structured validation errors;SubmissionController@submitcatches it and returns 422 with anerrorspayload. Other failures may surface asRuntimeExceptionwith 400 in the same action. - Domain invariants: e.g.
InvalidFormStatusTransitionException,FormNotActiveException,SubmissionNotFoundExceptionlive underDomain\…\Exceptionsfor clear semantics. - Global handler:
App\Exceptions\Handleris still the default Laravel stub. Centralizing mapping from domain exceptions to HTTP status codes (e.g. inregister()withrenderable) is the recommended extension point so every controller does not duplicate try/catch.
Eloquent models in app/Infrastructure/Persistence/Models are infrastructure; repositories map rows to domain entities (Form, Field, Submission) and back. Form schema versions are stored as snapshots (FormVersionModel) so submissions remain interpretable even if the live form later changes.
Routes are defined in routes/api.php:
- Public: list/show active forms, submit to
POST /api/forms/{id}/submit - Admin (Sanctum): CRUD forms and nested fields
- Submissions: list and show
A Postman collection is available at form_builder.postman_collection.json.
composer install
cp .env.example .env
php artisan key:generate
php artisan migrate
php artisan serveOptional: php artisan queue:work when using a non-sync queue driver.
-
Configure the database in
.env(DB_DATABASE,DB_USERNAME,DB_PASSWORD,DB_HOST). Create the empty database if it does not exist. -
Run migrations so tables match
database/migrations/(forms, form fields, versions, submissions, audit logs, users, etc.).php artisan migrate
-
Seed demo data (optional but useful for trying the API and Postman collection):
php artisan db:seed
Or recreate the schema and seed in one step (destructive: drops all tables):
php artisan migrate:fresh --seed
What the seeders do
| Seeder | Purpose |
|---|---|
UserSeeder |
Creates 10 users via User::factory(). Emails are random; every factory user uses the same hashed password (password) as defined in database/factories/UserFactory.php. Use any seeded row’s email with password to obtain a Sanctum token from POST /api/login. |
FormSeeder |
Inserts one active sample form, Customer Feedback Form, with fields (text, select, textarea with conditional logic, date), plus an initial form_versions row with a schema_snapshot so submission and public endpoints have a consistent version to read. |
Tips
- After seeding, list users in Tinker to copy an email:
php artisan tinkerthenUser::pluck('email');. - If you re-run
db:seedwithoutmigrate:fresh,FormSeedermay insert duplicate sample forms (it does not clear existing rows). Prefermigrate:fresh --seedfor a clean demo, or extendFormSeederto guard against duplicates if you need idempotent seeds.
This project uses DDD layering, repository + command/handler use cases, a pipeline for submission, strategy/factory for field behavior, cache-aside for read-heavy form metadata, and a queued job for submission follow-up. Domain exceptions express business failures; HTTP mapping is partially in controllers today and can be consolidated in the global exception handler as the API grows.