OpenSign for Symfony
OpenSign brings DocuSign-grade e-signatures to your own servers, but its Parse Server API fights back from Symfony. OSMBridgeBundle wraps that API in a typed service, turns completion webhooks into Symfony events, and boots a full OpenSign and MinIO stack locally.

Every HR and payroll product ends up in the same place: someone needs to sign something. A contract on the first day of employment. A payroll validation at the end of the month. A quote before a client renews. Our platform is no exception. The need arrived while we were rebuilding it through the Strangler Fig migration described in earlier articles on this blog, and e-signatures were a module we had deferred until the core was stable.
The decision came down to two roads. A signing SaaS like DocuSign or PandaDoc, which handles everything for you and bills you per document, per user, per month. Or self-hosting an open source signing platform, which puts the whole stack on your own infrastructure and leaves you responsible for it.
For a multi-tenant HR product, the second road wins for reasons that have nothing to do with budget. Employee contracts contain data you do not want routed through a third-party signing service. Every tenant's documents, signers, and audit trails would live on someone else's servers. And the compliance burden around "who signed, when, and what they agreed to" only gets harder to explain when the evidence is distributed across your infrastructure and theirs.
So we went self-hosted. This article is about what that involves, the wall we hit at the API layer, and the Symfony bundle we built to climb over it and then open-sourced.
The open source signing options in 2026
Three projects dominate the "run it yourself" conversation:
| Project | Stack | Strongest at |
|---|---|---|
| DocuSeal | Ruby on Rails, PostgreSQL | Fast form building, lightweight, privacy-first |
| Documenso | Next.js, PostgreSQL | Signing workflows, API, PKCS#12 document sealing |
| OpenSign | Node, React, MongoDB | A DocuSign-shaped UI, easiest for non-technical staff |
DocuSeal and Documenso are the bigger projects, and if your stack is PostgreSQL they integrate cleanly with your existing data. OpenSign is the younger sibling, hovering just under seven thousand stars, but it looks the most like the commercial product people already know. For an HR product whose end users include HR managers who have used DocuSign their whole careers, that familiarity matters. We chose OpenSign.
OpenSign is AGPL-3.0. That is a real consideration for any company: if you ship it as a service to third parties, the AGPL obligations attach to how you distribute or serve the code. We run it internally for our own tenants, so it is a constraint we can live with, but it is worth knowing before you commit.
The wall: OpenSign is Parse Server on the inside
The UI is pleasant. The admin panels, the signing flow, the audit trail, all of it looks like the products it imitates. Then you open the API docs and find something else: OpenSign's backend is a Parse Server instance, a MongoDB-backed BaaS, and its REST API speaks Parse's dialect.
That dialect is the real integration cost. There is no PHP SDK to speak of. Every object reference is a __type: Pointer fragment that must be assembled by hand. Every authenticated call needs an application ID header, a master key header, and for user-scoped operations a session token. A signature request, which is one concept, becomes a document object that references a file object that references a contactbook object that references a user object, and each of those references is a small JSON time bomb waiting to be mistyped:
{
"Title": "Employment Contract",
"File": {
"__type": "File",
"name": "contract.pdf",
"url": "https://minio.internal/osmb/contract.pdf"
},
"Signers": [
{
"Role": "Signer",
"Contact": {
"__type": "Pointer",
"className": "contracts_Contactbook",
"objectId": "cDx9qLv2Rw"
}
}
],
"CreatedBy": {
"__type": "Pointer",
"className": "_User",
"objectId": "sysUser01"
}
}
Every field name is case-sensitive and matches OpenSign's internal class names. contracts_Contactbook, contracts_Users, contracts_Document. These are not public API terms; they are the names of Parse classes in the OpenSign codebase. Get one wrong and the request silently creates an object with a null field, which surfaces days later as "the signer never received the email."
The guest signer flow is worse. To sign a document as an external person you cannot just pass an email. You create a Parse user, then you create a contactbook entry that points back to that user and to the system account that created it, and only then do you have the objectId you can put inside a Signer. That is two or three API calls and two levels of pointer indirection just to say "this email should sign this document."
The bundle: one service, five methods
Rather than scatter this dance across our application code, we extracted it into a Symfony bundle called OSMBridgeBundle. One service, OpenSignService, hides the Parse dialect behind a small set of typed methods.
$upload = $openSignService->uploadFile('contract.pdf', $pdfPath, 'application/pdf');
$signerId = $openSignService->createGuestSigner($clientEmail, $clientName);
$response = $openSignService->createSignatureRequest([
'Title' => 'Employment Contract',
'File' => [
'__type' => 'File',
'name' => $upload['name'],
'url' => $upload['url'],
],
'Signers' => [
[
'Role' => 'Signer',
'Contact' => [
'__type' => 'Pointer',
'className' => 'contracts_Contactbook',
'objectId' => $signerId,
],
],
],
]);
$signatureId = $response['objectId']; // store this on your entity
The pointer assembly is still visible here, because OpenSign insists on it. What the bundle removes is the plumbing around it. The master key and app ID headers are set once in the service. The session token is injected from configuration. createGuestSigner runs the create-or-find dance internally and returns the contactbook objectId you actually need. createSignatureRequest looks up the current user's ExtUser profile and attaches the CreatedBy pointer for you, two things that were easy to forget and expensive to debug.
The webhook becomes a Symfony event
Signatures finish on OpenSign's side, so the interesting part of the flow is asynchronous. When every signer completes, OpenSign posts a webhook, and the bundle exposes it as a route your app imports:
ossm_bridge_routes:
resource: "@OssmBridgeBundle/config/routes.yaml"
That route lands on POST /ossm/webhook. The controller validates the payload, and when it sees a completed document it dispatches a DocumentSignedEvent. Your application never parses OpenSign's payload shape. It subscribes to a named event:
final class DocumentSignedSubscriber implements EventSubscriberInterface
{
public static function getSubscribedEvents(): array
{
return [DocumentSignedEvent::NAME => 'onDocumentSigned'];
}
public function onDocumentSigned(DocumentSignedEvent $event): void
{
$contract = $this->contractRepository->findBySignatureId($event->getDocumentId());
$contract->markValidated();
$this->entityManager->flush();
}
}
That is the whole integration surface. The event carries the OpenSign object ID and the raw payload, so if you need more than "mark it validated" the data is there. But the default path, update your entity and move on, is a dozen lines in a subscriber.
The setup you never want to do twice
A fresh OpenSign install is not usable out of the box. Before the first document can move through the API you need a system user, a tenant, an organization, a team, a user profile, and the contracts_Document schema to exist. OpenSign's UI can create some of this, but a developer setting up a disposable environment wants a command, not a click session.
The bundle ships one: ossmb:opensign:setup. It walks the Parse API in the right order, creating each object and wiring the pointers between them, then writes the generated user ID and session token back into your .env file.
task opensign:setup
On the first run this boots the whole stack, provisions the system user, generates the credentials your app needs, and leaves you with a working signature platform instead of a rabbit hole of UI forms.
OpenSign stores files in MinIO
Documents are the product, so where the PDF bytes live matters. OpenSign hands file storage to Parse Server's files adapter, which by default writes to local disk or to DigitalOcean Spaces. For a local development stack, local disk means every docker compose down risks losing documents, and DO Spaces means needing cloud credentials in your dev environment.
The bundle's compose file removes both problems. It runs a MinIO container, an S3-compatible object store, and mounts a small patch over OpenSign's server entry point that points the files adapter at MinIO with presigned URLs:
fsAdapter = new S3Adapter({
bucket: 'osmb',
s3overrides: {
credentials: {
accessKeyId: process.env.DO_ACCESS_KEY_ID,
secretAccessKey: process.env.DO_SECRET_ACCESS_KEY,
},
endpoint: 'http://minio:9000',
signatureVersion: 'v4',
forcePathStyle: true,
},
});
The full flow looks like this:
sequenceDiagram
participant App as Symfony app
participant OS as OpenSign
participant MinIO
participant Signer as Signer
App->>OS: uploadFile()
OS->>MinIO: store PDF via S3 adapter
App->>OS: createGuestSigner()
App->>OS: createSignatureRequest()
OS->>Signer: email with signing link
Signer->>OS: signs the document
OS->>App: webhook POST /ossm/webhook
App->>App: dispatch DocumentSignedEvent
MinIO gives you the S3 API without the cloud dependency. The same adapter settings that work against DigitalOcean Spaces work against MinIO, which means the production setup can stay on Spaces or move to any S3-compatible store, and the development setup costs nothing and survives container restarts.
Wiring it into your Symfony app
The bundle installs like any other. Require it, register it, configure it:
composer require ossm/ossm-bridge-bundle
# config/packages/ossm_bridge.yaml
ossm_bridge:
opensign:
app_id: "%env(OPENSIGN_APP_ID)%"
master_key: "%env(OPENSIGN_MASTER_KEY)%"
api_url: "%env(OPENSIGN_API_URL)%"
user_id: "%env(OPENSIGN_USER_ID)%"
session_token: "%env(OPENSIGN_SESSION_TOKEN)%"
The config is validated strictly. Leave app_id, master_key, or api_url empty and the container fails to compile, which is the right behavior: a silently misconfigured signature service is worse than a loud one. OpenSignService then drops into any of your services through normal constructor injection.
What to know before you adopt it
Three things are worth flagging, because they surprised us and they shape how you use the bundle.
OpenSign is Parse Server, and that is the whole game. The bundle hides the dialect, but you still reason about Parse concepts. The classes are named contracts_*, the objects are keyed by objectId, and the webhook payload carries a status string that you must trust. None of that changes the product experience for your users, but it means the mental model is Parse, not "a signing API."
OpenSign wants MongoDB. The default deployment bundles a MongoDB instance, and the bundle's compose file runs one alongside it. If your infrastructure is PostgreSQL-only, this is a second database to operate. DocuSeal and Documenso avoid that, at the cost of a less familiar UI.
The license is AGPL-3.0, and the audit trail is on you. Self-hosting means you own uptime, backups, and the evidence trail. OpenSign generates completion certificates and timestamped logs, but "we hosted it" becomes your answer to any compliance question, so treat the signing store as production infrastructure from day one.
Why open source it
The bundle started as a workaround for one platform. Then it became the part of that codebase we were proudest of, the part where the ugliness was contained and the rest of the application never had to look at a Parse pointer. Extracting it was a small refactor, and publishing it was a bet that the same wall exists for other Symfony teams.
If you are on Symfony, need e-signatures, and would rather not bill a SaaS for the privilege, the bundle is at github.com/imdela/OSMBridgeBundle. It is MIT-licensed, so take it, change the class names, or lift just the service and leave the rest. The Parse dialect is the part nobody should have to write twice.
— Delaa