Architecture¶
← README · Security design · Roadmap · Releases
Paperless Sync is a Nextcloud app in PHP. It runs inside Nextcloud, reads Paperless-ngx through its REST API and writes files through Nextcloud's file API. It has no server, daemon or port of its own.
Components¶
How a run starts, and which systems it reaches:
---
config:
flowchart:
nodeSpacing: 24
---
flowchart TB
accTitle: How a run starts and what it reaches
accDescr: An administrator configures the app and starts a dry run on the settings page, whose controllers start the synchronization, or a run, which the controllers queue as a background job. Nextcloud's cron starts the scheduled background job and the queued one, which start the synchronization as well. On the server, the administrator starts runs with the occ commands. The synchronization builds the path of every document with the path templates, reads Paperless-ngx through the Paperless client, which calls its REST API with the token, and writes the files of the target user through the storage. Everything but the administrator and Paperless-ngx runs inside the Nextcloud server.
admin(["Administrator"])
subgraph nextcloud ["Nextcloud server"]
settings["Settings page<br>and controllers"]
commands["occ<br>commands"]
cron(["Nextcloud cron"])
job["`Background jobs<br>*scheduled, and<br>Synchronize now*`"]
sync["Synchronization"]
template["Path<br>templates"]
storage["Storage"]
client["Paperless<br>client"]
files[("Files of the<br>target user")]
end
paperless[("Paperless-ngx")]
admin -- "configure,<br>dry run, run" ---> settings
admin -- "run, status,<br>forget" ---> commands
cron --> job
settings -- "dry run" --> sync
settings -- "queue a run" --> job
commands --> sync
job --> sync
sync --> template & storage & client
storage --> files
client -- "REST API<br>with the token" --> paperless
%% An invisible link draws Paperless-ngx below the Nextcloud server, not beside it.
files ~~~ paperless
classDef paperless fill:#17803d2e,stroke:#17803d,stroke-width:2px
classDef nextcloud fill:#0082c92e,stroke:#0082c9,stroke-width:2px
class paperless paperless
class files nextcloud
Where the app keeps its data in Nextcloud:
---
config:
flowchart:
nodeSpacing: 24
---
flowchart TB
accTitle: Where the app keeps its data
accDescr: The settings page and the synchronization read and save the configuration, which keeps the settings in the app configuration of Nextcloud and the API token in its credentials manager. The synchronization keeps the state of every exported document and every file of the inbox in two tables of the database, and the status of the latest run in the app configuration.
settings["Settings page<br>and controllers"]
sync["Synchronization"]
config["Configuration"]
state["State"]
status["Status"]
appconfig[("App<br>configuration")]
credentials[("Credentials<br>manager:<br>API token")]
database[("Database:<br>two tables")]
settings --> config
sync --> config & state & status
config --> credentials & appconfig
status --> appconfig
state --> database
classDef nextcloud fill:#0082c92e,stroke:#0082c9,stroke-width:2px
class appconfig,credentials,database nextcloud
| Component | Files | What it does |
|---|---|---|
| Settings page | lib/Settings/, templates/settings.php, js/settings.js |
The page under Administration settings → Paperless Sync: the configuration, a test of the connection, a dry run, a run by hand and the status of the last run |
| Controllers | lib/Controller/ |
The routes of the settings page: save, reset, run and status. A dry run runs in the request; a run is queued as a background job, answered with 202 Accepted. Nextcloud lets only administrators call them and checks the CSRF token of every request, but for the webhook |
| Webhook | lib/Controller/WebhookController.php, lib/Service/WebhookService.php, lib/Cron/DocumentSyncJob.php |
The public route that a workflow of Paperless calls with the ID of a document it added or changed, with a secret of its own, whose SHA-256 alone the credentials manager keeps; the run of that document, during the call or as a queued background job, and the last call for the settings page (decision 0019) |
| Configuration | lib/Service/ConfigService.php, lib/Model/SyncConfig.php |
Checks every setting and stores it in the app configuration of Nextcloud; the API token goes to Nextcloud's credentials manager |
| Paperless client | lib/Service/PaperlessApiService.php, lib/Service/PaperlessClientFactory.php, lib/Model/PaperlessConnection.php |
Reads documents, their metadata, tags and the trash, downloads files, uploads the files of the inbox and follows their tasks, through Nextcloud's HTTP client, in version 9 of the Paperless API (decision 0012). The factory makes a client for one connection, a URL with its token and whether it may be in the local network; the client never reads the settings, so every request of a run goes where the run started (decision 0020) |
| Path templates | lib/Service/PathTemplateService.php |
Turns the metadata of a document into its path, such as Example GmbH/Invoice/2026/2026-08-26 - Example invoice [P123].pdf, with every part cleaned for Nextcloud, macOS and Windows |
| Metadata next to the archive | lib/Service/MetadataSidecars.php |
When the settings ask for it, what Paperless knows of each document, as .paperless/P123.json in the archive folder, and the index .paperless/index.csv, whose SHA-256 of each sidecar lets a run write only the sidecars whose content changed; a sidecar goes with its copy, under the rules of deletion safety |
| Storage | lib/Service/NextcloudStorageService.php |
Creates folders and writes, moves and deletes files below the base folder of the target user, through Nextcloud's file API: a new file next to its path first, an existing one in place |
| Synchronization | lib/Service/SyncService.php |
One run: first the inbox, then the export with moves and renames, the trash and the guarded deletion; a lock and the signs of life of a run, which outlast an expired lock, keep two runs apart. The run of one document, which the webhook asks for, takes the way of the export alone, with a batch of one. It forgets the content of copies, so that the next runs write them again |
| State | lib/Service/SyncStateRepository.php, lib/Service/ExportStates.php, lib/Migration/ |
Two tables in Nextcloud's database: paperless_sync_export with the path, the SHA-256 and MD5 checksums, the checksum that Paperless reported for the revision and the missing runs of every exported document, and paperless_sync_import with every file of the inbox and its Paperless task, or the refusal of Paperless, or the failed attempts and the time of the next one |
| Background jobs | lib/Cron/SyncJob.php, lib/Cron/ManualSyncJob.php, lib/Cron/DocumentSyncJob.php |
Start a run from Nextcloud's cron: the scheduled one when synchronization is enabled and the configured interval has passed, the queued one of Synchronize now once, and the run of a document that the webhook queued |
| Commands | lib/Command/ |
occ paperless_sync:run, paperless_sync:status and paperless_sync:forget: a run or a dry run in the shell, with a batch size of its own or none, the status of the last run, and copies to write again, as text or JSON, with exit codes |
| Status | lib/Service/StatusService.php, lib/Model/SyncReport.php |
The start, the end, the state, the summary, the error and the report of the latest run, the end of the last successful run, and the time of the next scheduled run; the report lists errors and deletions first, each change as a line in English for the command line and as an entry of its kind, document and file, which the settings page shows in the language of the administrator |
| Activity | lib/Activity/ |
The entries of the target user in Nextcloud's Activity app, with the setting and the filter of the app there: after a run that changed something, one entry with the counts of its changes and the IDs and paths of its first five files, and one entry for each import that Paperless finished, failed or refused. The entries keep IDs and paths; the provider puts them into words in the language of the reader when the Activity app shows them |
| Notifications and health | lib/Notification/, lib/SetupChecks/, lib/Controller/StatusApiController.php |
The status counts the runs in a row, not dry-runs, that failed or completed with errors. After the third, or a day after the first, every administrator, or the group of the app setting notification_group, gets one notification, which the next success removes; the target user gets one for each import that failed or was refused. Two setup checks of Administration → Overview, whose warnings the settings page shows too, and the admin-only OCS route /api/v1/status for monitors report the same health |
Data flow¶
One run, from the background job to the status:
---
config:
sequence:
wrap: true
width: 120
actorMargin: 20
diagramMarginX: 8
---
sequenceDiagram
accTitle: One run of the synchronization
accDescr: A background job, the settings page with a dry run, or an occ command starts a run. Paperless Sync takes the lock, checks the configuration, and tests the connection to Paperless-ngx and the base folder in Nextcloud. Inbox: it asks Paperless for the tasks of earlier uploads, removes imported files from Nextcloud and moves failed ones, uploads new, changed and due files to Paperless, and stores their tasks, refusals and retries in the database. Export: it reads every page of the documents, the trash and the metadata from Paperless, checks that every list came back complete, compares their paths and checksums with those in the database, downloads new and changed documents, writes, moves, trashes and deletes their files, and saves paths, checksums and missing runs. Finally it prunes empty folders, records the status and releases the lock.
participant Paperless as Paperless-ngx
participant Sync as Paperless Sync
participant Nextcloud as Nextcloud
Note over Paperless,Nextcloud: started by a background job, a dry run of the settings page, or occ
Note over Sync: take the lock, check the configuration
Sync->>Paperless: test the connection
Sync->>Nextcloud: test the base folder
rect rgba(0, 130, 201, 0.12)
Note over Paperless,Nextcloud: Inbox
Sync->>Paperless: the tasks of earlier uploads
Sync->>Nextcloud: remove imported, move failed files
Sync->>Paperless: upload new, changed and due files
Sync->>Nextcloud: save tasks, refusals, retries
end
rect rgba(23, 128, 61, 0.12)
Note over Paperless,Nextcloud: Export
Sync->>Paperless: every page of documents, trash, metadata
Note over Sync: check that every list is complete
Sync->>Nextcloud: compare with saved paths, checksums
Sync->>Paperless: download new and changed ones
Sync->>Nextcloud: write, move, trash, delete files
Sync->>Nextcloud: save paths, checksums, missing runs
end
Note over Sync: prune folders, record the status, unlock
- Nextcloud's cron starts the background job every five minutes. It runs only when synchronization is enabled and the configured interval has passed since the last run that synchronized; a dry-run counts only while it is still running. An administrator can start a dry run on the settings page, which runs in the request, and a run, which the page queues as a background job of its own,
ManualSyncJob, that the next background jobs start; the page follows it through the status.occ paperless_sync:runstarts a run or a dry run in the shell, with the batch size of the settings, another or none (decision 0018). - The run takes a lock, reads its settings and its connection to Paperless together, with one decryption of the API token, checks that the app is configured, and tests the connection to Paperless and the base folder in Nextcloud. It keeps that connection to its end: settings saved during the run reach the next run, as the picture below the steps shows.
- Inbox: the files of the inbox folder are uploaded to Paperless, at most as many as the batch size allows. Each file goes up from a copy in Nextcloud's temporary folder, so that the request has a
Content-Lengthalso when the file lies on object storage, with the tags of its subfolders and those for every import, which the run looks up once among the tags of Paperless and creates when it may (lib/Service/InboxTags.php). Their tasks are followed in the next runs: after success the file is deleted or kept, as configured, and the report names the new document; after a failure, a duplicate or a task that Paperless revoked, it moves to the error folder, next to a text file with the reason and the ID of the task. A task that Paperless has not finished a day after the upload ends by the content of the file: a document with its checksum in Paperless is a success; a task that Paperless still lists, unfinished, moves the file to the error folder, because Paperless-ngx 3.x would import a second upload as a duplicate; a task that Paperless doesn't list sends the file to the wait of a failed upload, after which it goes up again. A file that Paperless refuses at once, with400,413,415or422, is remembered with its ETag and skipped until it changes. Any other failure, such as a timeout or a5xx, ends the uploads of the run, and the file waits for its next attempt: 15 minutes, doubled after each failure, at most a day. - Export: every document gets its path from the path template. A new document is downloaded to a temporary file, which goes on to Nextcloud as it is, in chunks; one pass over it computes its SHA-256 and MD5 checksums, so that no document is in memory as a whole. A document with a new revision in Paperless is compared by the checksum that Paperless reports, MD5 up to Paperless-ngx 2.x and SHA-256 since 3.0: when it is one of the saved two, a document whose metadata changed moves to its new path; otherwise the document is downloaded, and its file moves to its new path, if it has one, and gets the new content in place. A copy at the path of a document with its content stays as it is, also when the document comes back from the Paperless trash or after it was missing. A file that an earlier version exported has only its SHA-256 saved; when Paperless reports an MD5, one read of the mirrored file adds it, as long as the file still has that SHA-256. Documents in the Paperless inbox and documents with an excluded tag are skipped, and their mirrored copies removed. Every change takes its place in the batch, also one that fails, and failed exports take their places only among the exports. The most recently added documents come first, documents whose last change failed after the others. Before every write, the app checks the free space, because Nextcloud applies no quota to the temporary file of a write; a full quota, an archive that is no longer writable or an unavailable storage ends the exports and moves of the run at its first failure.
- Trash and deletion: a document in the Paperless trash moves to the deleted folder. A document that is missing from both the API and the trash for the configured number of complete runs in a row moves to the deleted folder too, and is deleted only when permanent and direct deletion are enabled. A scan is complete when every page came back full, with the same count of Paperless, and the run received every document that Paperless listed, read in the order of their IDs; after an incomplete scan, no document counts as missing.
- Empty folders are pruned, the state is saved, and the status records the report. The Activity stream of the target user gets an entry for the changes of the run and one for each import result, also when the run failed after its first changes. A run that failed or completed with errors counts towards the notification of the administrators, and a failed or refused import notifies the target user. A dry run does all of this without writing anything, and adds no entry and no notification.
The connection of a run, from its start to its end:
---
config:
flowchart:
nodeSpacing: 24
---
flowchart TB
accTitle: One connection to Paperless for the whole run
accDescr: At its start, a run reads its settings and its connection to Paperless together from the app configuration and the credentials manager, with one decryption of the API token, which joins the connection only for the URL that it was saved with. The client factory makes a client of Paperless for that connection alone, and every request of the run, the test of the connection, the inbox and the export, goes through that client to the URL of the connection with its token. Settings that an administrator saves during the run reach the next run, which reads them at its start.
admin(["Administrator"])
subgraph nextcloud ["Nextcloud server"]
store[("App configuration and<br>credentials manager")]
subgraph run ["One run"]
snapshot["`Settings and connection
*URL, token, local network*`"]
factory["Client factory"]
client["`Client of
*this connection alone*`"]
end
end
paperless[("Paperless-ngx")]
admin -- "saves settings,<br>also during a run" --> store
store -- "once, at its start:<br>one decryption<br>of the token" --> snapshot
snapshot -- "connection" --> factory
factory -- "connect" --> client
client -- "every request of the run:<br>test, inbox and export,<br>to the URL with the token<br>of the connection" --> paperless
classDef paperless fill:#17803d2e,stroke:#17803d,stroke-width:2px
classDef nextcloud fill:#0082c92e,stroke:#0082c9,stroke-width:2px
class paperless paperless
class store,client nextcloud
Tests¶
Three kinds of tests check the app: unit tests, which scripts/check.sh runs before every push, end-to-end tests in Docker, and mutation tests, which check the unit tests themselves.
flowchart TB
accTitle: The synchronization in the unit tests
accDescr: A unit test sets up Paperless-ngx, the files of Nextcloud, the state, the configuration and the clock in memory, and runs the synchronization of the app through the interfaces it uses in Nextcloud. Then the test checks the report, the files of every user, the state of every owner, the status and the record of every call.
test(["Unit test"])
sync["The app:<br>the code<br>under test"]
subgraph doubles ["Doubles in memory"]
paperless["Paperless-ngx:<br>documents,<br>trash, uploads,<br>tasks"]
storage["Nextcloud:<br>each user's<br>files apart,<br>every call<br>recorded"]
state["State:<br>each owner's<br>apart"]
platform["Settings,<br>token and a<br>clock that<br>stands still<br>until a test<br>moves it"]
end
test -- "set up, run" --> sync
sync --> paperless & storage & state & platform
test -. "check the report,<br>files, state, status<br>and calls" .-> doubles
classDef check fill:#526cfe2e,stroke:#526cfe,stroke-width:2px
classDef paperless fill:#17803d2e,stroke:#17803d,stroke-width:2px
classDef nextcloud fill:#0082c92e,stroke:#0082c9,stroke-width:2px
class sync check
class paperless paperless
class storage,state,platform nextcloud
- Unit tests in
tests/Unitrun every line oflib/;scripts/check.shfails when one runs in no test. The synchronization runs as in Nextcloud, against doubles in memory behind the same interfaces. The doubles are strict, so that a test sees what a mistake of the app would do: the files keep each user apart and record every call with its user, the state keeps each owner apart, Paperless serves an original that differs from the archived version and records every download, every connection that a run takes and every test of it, and Nextcloud's clock stands still until a test sets or advances it, so that no test depends on the time it runs at. - End-to-end tests in
tests/e2erun the app in every supported version of Nextcloud, in Docker, against a mock of Paperless-ngx (decision 0005). - Mutation tests change the code of
lib/in more than two thousand small ways with Infection and run the unit tests against each change, a mutant. A mutant that no test notices shows a line that the tests run but don't check. The workflow Mutation runs them every week and reports the score, the MSI: the share of the mutants that the tests noticed.
| Measured | Mutants | Noticed | Not noticed | MSI |
|---|---|---|---|---|
| 2026-10-14, before #114 | 2,287 | 2,005 | 282 | 87.7 % |
| 2026-10-14, after #114 | 2,295 | 2,205 | 90 | 96.1 % |
No mutant of the deletion guards of decision 0004 survives. Most of the others change nothing that the app does: a break in place of the continue at the end of a loop, a cast of a value that is a string already, the default of a key that every row has, or a limit of the wait between uploads that the wait of a day caps anyway. The same run on your computer, with PHP 8.3 and the version and checksum of Infection from .github/workflows/mutation.yml. Everything stays in the ignored folder build/, because the workflow uses a committed infection.json5 in place of its own:
mkdir -p build
curl -fsSL -o build/infection.phar https://github.com/infection/infection/releases/download/0.35.6/infection.phar
echo "3fd4216875260b8e47c48ec1a92473f500d88bd36d8ffec21cc0d24e18d7f0ea build/infection.phar" | sha256sum --check --strict
echo '{"bootstrap": "../tests/bootstrap.php", "source": {"directories": ["../lib"]}, "timeout": 30, "logs": {"text": "infection.log"}}' > build/infection.json5
php build/infection.phar --configuration=build/infection.json5 --threads=max --with-uncovered --min-msi=0 --min-covered-msi=0 --no-progress
Design decisions¶
- Paperless is the source of truth. Nextcloud gets a mirror; the app sends no change of the mirrored files back to Paperless.
- The marker
[P<ID>]in every file name ties a file to its document, so a renamed document is found again, and Paperless Unified Search can open it. The app adds it to a template that leaves it out, and it shortens only the free text of a name that would be too long, never the marker. - No WebDAV and no password of Nextcloud. The app writes through Nextcloud's file API in the name of the target user.
- Mirrored files keep their identity. Nextcloud ties shares, public links, comments, tags, favorites and versions to the ID of a file, so the copy of a document stays the same file: a change of the metadata moves it, and a change of the content writes it in place with
File::putContent(), under Nextcloud's exclusive lock. The versions app keeps the previous content as a version in every run: it looks for the file among the files of the user who is logged in, and a run never runs in the request of a user, since Synchronize now queues a background job (decision 0018). Only a new file is written to a temporary.partfile first and moved into place. The trade-off: on local storage, a write in place that fails halfway, such as on a disk that runs full, leaves the file incomplete until the next run writes it again; the document is complete in a temporary file of the server by then, so the window is as short as a local copy. With the conflict policy Skip and report conflict, the app's own copy is no conflict: the state records its path, and the file still has the content of the last download, or the state marks it as being written, so that a copy that a failed write left incomplete is written again. A copy that someone changed stays, also when its document moves to a new path (decision 0010). - Both checksums of every download. The database keeps the SHA-256 and the MD5 of every file, so that the checksums of Paperless-ngx 2 and 3 both tell a change of the metadata, which moves the file, from a change of the content (decision 0009).
- One connection for each run. A run reads its settings and the API token once, at its start, and reaches Paperless through a client of that connection alone, so a save during the run never mixes two Paperless in one run and the token is decrypted once, not with every request (decision 0020).
- Bounded and resumable runs. The batch size limits the changes of one run, failed ones included, and the state lets the next run continue where it stopped.
- A run costs what changed. A run asks Paperless for the documents with only the fields that it reads, listed in
PaperlessApiService::DOCUMENT_FIELDS, so their text, notes and permissions stay in Paperless; a test fails when the code reads a field of a document that the list leaves out, such as a new variable of the path template. It asks for the checksum of a document once for each revision: when the batch is full, a document with a new path waits without the request, and the state keeps the checksum with its revision for one that waits at its path or whose change fails. The run reads the state of every export once, at its start, and writes a state only when one of its values changes, so a run over an archive without changes writes nothing to the database. The settings and the status are lazy keys of the app configuration, which Nextcloud loads when the app asks for them, not with every request of every user. - Only complete scans confirm a deletion. A page that came back short or empty, or a list that changed while the run read it, makes no document count as missing (decision 0004).
- Safe by default. Synchronization is off until a configuration works, and permanent deletion is off until an administrator turns it on.