Zum Inhalt

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
  1. 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:run starts a run or a dry run in the shell, with the batch size of the settings, another or none (decision 0018).
  2. 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.
  3. 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-Length also 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, with 400, 413, 415 or 422, is remembered with its ETag and skipped until it changes. Any other failure, such as a timeout or a 5xx, ends the uploads of the run, and the file waits for its next attempt: 15 minutes, doubled after each failure, at most a day.
  4. 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.
  5. 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.
  6. 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/Unit run every line of lib/; scripts/check.sh fails 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/e2e run 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 .part file 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.