Home›Packora›Manual
Packora · Manual

Packora Manual

Packora — User Manual

A packaging producer register: SKUs, materials, weights and the tonnage a report asks for. Version 4.1.3 [src: app/controllers/api.php:173].

About this manual

Every statement here was written by reading Packora's own source, and each one carries the file it came from in a bracketed src marker. Paths are relative to the folder this docs directory sits in. If a sentence disagrees with the application, the application is right and this manual has a bug — the markers exist so you can check rather than take our word for it.

Nothing is described that the code does not do. Where something is deliberately limited, that is said plainly rather than left out.

Read this before anything else

Packora ships no built-in fee schedules, no thresholds and no statutory deadlines. Rates and deadline dates are entered by you, and every estimate is labeled as an estimate [src: app/src/Register.php:13].

That is the product's own stated position, not a caveat added by this manual, and it is the right one. Nothing in Packora tells you what any scheme charges, what any threshold is, or when anything is due. It multiplies weights you supply by units you supply, and applies a rate you supply.

Nothing here is legal or compliance advice. Confirm your obligations with your producer responsibility organization or your adviser.

What it computes

Three lines of arithmetic, stated in the source [src: app/src/Register.php:5]:

line weight    = component weight (mg) × units sold
material total = Σ line weights per material, per state × year
fee estimate   = material mg × your rate (¢/kg), rounded half up in integer space

Everything stays in integer milligrams and integer cents until the final display conversion [src: app/src/Register.php:9]. Sums are done in PHP rather than in SQL, deliberately, so both database engines produce identical integer results [src: app/src/Register.php:10].

Negative inputs floor at zero rather than subtracting [src: app/src/Register.php:27].

No rate means no estimate — never a zero

If you have not entered a rate, the fee comes back as nothing at all, and the source says why in the same breath: "no estimate", never a fake $0 claim [src: app/src/Register.php:34].

A product that showed £0.00 for an unpriced material would be inviting somebody to file on a number that was never calculated.

The arithmetic itself is integer throughout, with a documented fallback used only at magnitudes beyond 64-bit safety — far past any real input [src: app/src/Register.php:35].

Deadlines

A deadline badge is one of four states, and the first matters most: none, meaning no date has been entered — "verify with your PRO" [src: app/src/Register.php:50]. Then overdue, due soon, or fine.

Due-soon defaults to 60 days [src: app/src/Register.php:22].

The reminder ladder

You set rungs — say 60,30,7 — and each fires once per deadline date. The source gives the reason in a sentence worth keeping: one warning two months out is a rumor, not a reminder [src: app/src/Register.php:69].

Your input is parsed strictly, and the strictness is deliberate. The parser splits on separators and then demands a clean positive integer, rather than splitting on "not a digit" — because the lazy version would turn a typed -5 into a five-day rung, giving you a reminder you never asked for, which the source calls worse than being told your input was ignored [src: app/src/Register.php:75].

Rungs beyond a year are dropped, because a year out is not a reminder [src: app/src/Register.php:82].

An overdue deadline always maps to rung zero, so a missed filing keeps being reported rather than falling off the bottom of the ladder [src: app/src/Register.php:92].

Reminders latch, so nothing fires twice

A reminder is sent only when the ladder tightens — never twice for the same rung, and never again on a re-run [src: app/src/Register.php:108].

A new or changed deadline date starts the ladder fresh [src: app/src/Register.php:114].

So you can run the reminder job hourly, or twice, or after restoring a backup, and nobody is emailed the same warning again.

When the ladder is checked

It is checked each time someone opens the dashboard, and by a token-guarded cron route, reminders/run?t=<token>, using the scheduled-backup token [src: app/controllers/v3.php:592]. Both paths call the same watcher with the same latch [src: app/controllers/api.php:58], so a daily cron job sends each step in the months nobody opens Packora, and never twice.

Completeness — the gaps are reported, not hidden

Two reports exist for exactly the failure that makes a register wrong without looking wrong.

SKUs with no packaging components are listed [src: app/src/Register.php:221]. Their sales weigh nothing in the register — the tonnage is understated and nothing on the report would tell you.

Sales rows whose SKU code matches no SKU are flagged, and the source is explicit that they are never silently dropped [src: app/src/Register.php:229].

Those two lists are the difference between a total you can file and a total that merely adds up.

Exporting

CSV to RFC 4180 with formula-injection neutralization: a cell beginning =, +, -, @, tab or carriage return gets a leading apostrophe unless the value is numeric, so -5 and -12.5 export as real negative numbers while =SUM(A1) and +HYPERLINK(...) are defused [src: app/src/Register.php:246].

Packaging that changes from a year

A component row counts for the reporting years it covers: every year until you say otherwise. When a carton gets lighter or a bottle changes material weight, open the SKU and use Weight changes from a year: pick the row, the new weight and the first reporting year it applies to. Packora ends the old row the year before and starts a new row with the new weight from that year [src: app/src/Register.php, versionFrom()]. The years already reported keep the weight they were computed with, so regenerating 2025 in 2027 gives the 2025 figures. Save on a row still corrects it for every year it covers: use it for a typing mistake, not for a packaging change.

The SKU page shows each row's years ("every year", "until 2025", "from 2026") and the unit weight for your default reporting year. The components CSV takes an optional from_year column that does the same split; without it, re-importing corrects the current row as before. Over the API and the upsert_sku tool, each component may carry from_year and to_year; a SKU whose rows already have years is refused (409) unless each component says its years, so a year-blind sync cannot overwrite a filed year.

Units sold from your shop's orders

Import CSV → 4 · Import orders from your shop reads an orders export with one row per order line: Shopify's Orders export, an order-export plugin's file from WooCommerce, or Amazon Seller Central's All Orders report (tab-separated). Pick the source; if your file's headers differ, type them under Column names. Packora adds up units per SKU, ship-to state and year (from the order date) and adds them to the units on file [src: app/src/Importer.php, importOrders()].

Each order line counts once, ever: the lines already counted are recorded, so importing the same file again, or two exports that overlap, never counts a line twice. Canceled, refunded, voided, failed, pending and on-hold orders are not counted; neither are orders shipped outside the US or to a state not on your States page. The dry run reports every one of those, including the units that went to states you have not added, and nothing is written until you confirm. Where an export fills an order's own columns on its first line only, the later lines of that order inherit them. Refunds inside an order that still counts are not subtracted; adjust those units by hand if your program asks you to. Clearing a year's units, deleting a units row or deleting a SKU also forgets the order lines behind them, so the orders can be imported again.

Fee per unit sold (3.3.0)

Under the estimate, the Fee estimator divides the state's estimated fee by the units sold there that year, counting the SKUs on file ("≈ $0.050 per unit sold into OR in 2026"). Every state lists each state you sold into that year with its units, estimated fee and fee per unit, highest first [src: app/src/Register.php:569], so a small seller can weigh what a state's fees cost against what they sell there. The figures come from the rates you entered; a state with no rates shows no figure rather than $0, and one with some rates missing is marked "rated materials only".

State names and units, checked

State names as people type them. Both the units import and the orders import read a state as its USPS code, its full name in any case, or a common abbreviation ("Cal", "Calif.", "N.Y.", "Wash.", "D.C."), and land it on the state you added under that code [src: app/src/Register.php:522]. A code Packora does not recognize is matched as typed, so a state you added under your own code still imports.

Grams against kilograms. A component weight of 20 kg or more per unit, or under 1 g, is flagged: on the SKU page ("check unit"), in the component import's report and on the dashboard's "Needs your action" [src: app/src/Register.php:541]. These are the weights that usually mean kilograms typed into a grams field (0.54 for a 540 g carton). Nothing is refused: a small label can weigh under a gram.

Registration, recyclability, eco-modulation and your PRO's format

Producer registration. On States & deadlines, each state has a registration record you keep: status (registered, applied and pending, not required for us, lapsed, or not recorded), who you are registered with, the number and the renewal date [src: app/controllers/app.php, state_registration_save()]. The dashboard lists states you sold into that year with nothing recorded (or lapsed), and renewals due within 60 days or past [src: app/src/Register.php, registrationGaps()]. Whether a state requires you to register is not something Packora decides; it reports your own record.

Recyclability and recycled content. Each component carries your recyclable flag and, since 3.2.0, its recycled content as a percentage of its weight. The SKU page shows the share of the packaging weight flagged recyclable and the recycled-content share for the default year; the report builder shows both for a state and year, weighted by the units sold [src: app/src/Register.php, recyclabilityOf()]. Both come from your figures; they are not a certification. The API carries them per SKU, and the components CSV takes a recycled_pct column.

Eco-modulation. Where your PRO's schedule adjusts fees by recyclability, enter it on the fee estimator beside each rate: a percentage added to (or, negative, taken off) the fee on the weight you flagged recyclable, and one for the rest [src: app/src/Register.php, modulatedFeeCents()]. The estimate then shows each material's flat fee and the adjustments, and the total beside what the flat rates alone would give. With both adjustments at 0 the estimate is exactly the flat one. It remains an estimate from your figures.

Your PRO's format. On the report pack, set the state's template layout once: rows per material or per SKU component, the columns in order with their exact headers, the weight unit (kilograms, pounds, tonnes or grams), the decimals and the separator [src: app/controllers/app.php, report_format_save()]. In <state>'s PRO format then downloads that year's figures in that layout. No PRO's format ships with Packora: the layout is the one your PRO gave you.

Recorded filings

A packaging change recorded from a year leaves earlier years as they were (see above), but a row corrected with Save changes every year it covers. When you file, an administrator can record the pack for that state and year: the material totals and the SKU-level rows exactly as they stand, their total and a SHA-256 of them, with who recorded it, when and a note such as the PRO's confirmation number [src: app/controllers/app.php:960]. The report page lists recorded filings and says whether the register still computes the same total today [src: app/controllers/app.php:974]; each can be downloaded again as CSV exactly as recorded [src: app/controllers/app.php:1006]. Recording files nothing with anyone.

Users and roles

Three roles [src: app/src/Auth.php:50]. Packora had none before this version, and the migration defaults every pre-existing account to admin so an upgrade cannot take rights away from anyone who already had them [src: app/src/Auth.php:41].

The line the source draws is a good one [src: app/src/Auth.php:46]:

A member keeps the register — SKUs, components, units sold, imports. They cannot change fee rates, jurisdictions, users or settings, because those decide what the filing SAYS rather than what it contains.

Permissionviewermemberadmin
.view permissions, report.viewyesyesyes
audit.viewyesyesyes
sku.*, component.*, sales.*, import.*—yesyes
view.save—yesyes
rates, jurisdictions, settings, users——yes

The audit trail is granted to a viewer explicitly — audit.view is named in the viewer's list rather than arriving through the wildcard [src: app/src/Auth.php:53]. That is a deliberate decision here, not an accident: the viewer role is described as reading "the register, reports and the audit trail" [src: app/src/Auth.php:44].

Backups follow the model

Packora's database backup downloads are permission-gated, requiring the settings permission [src: app/controllers/api.php:496]. A viewer cannot download the database.

The API and agent access

Six tools are exposed to an agent, of which one writes [src: app/controllers/v3.php:65]: listing SKUs, SKU detail, a tonnage report, a states summary and a data-gaps report on the read side; upserting a SKU on the write side.

The instructions make one guarantee worth having: nothing here re-derives a tonnage — the reports come from the same functions the register screen renders [src: app/controllers/v3.php:63].

So an agent's figures and your screen's figures cannot disagree. There is no second implementation to drift.

What Packora does not do

It does not know your fees, thresholds or deadlines [src: app/src/Register.php:13]. You enter them.

It does not show a fee where no rate was entered [src: app/src/Register.php:34].

It does not file anything, and it is not advice. It produces the tonnage a report asks for from data you supplied.

It does not hide an incomplete register [src: app/src/Register.php:221], and it does not drop a sales row it cannot match [src: app/src/Register.php:229].

It does not send the same reminder twice [src: app/src/Register.php:108].

Looking after your install (from 4.0)

Updates from inside Packora

Updates in the menu opens the Updates page (/updates) [src: app/views/updates.php]. Paste your license key once and press Save key: either key works, the one in your Lemon Squeezy receipt or the one on your order page (it starts with OWX-). Check for updates asks the store for the newest release your license covers; Packora never checks by itself.

When a new version is ready, Back up and update to that version backs up your data and the current version, installs the new one and checks it. If the update is interrupted, or the new version's database step or that check fails, the previous version is put back by itself. Packora is unavailable for about a minute while this runs. The update replaces Packora's files, including any you edited, except .htaccess, .user.ini and robots.txt: an edited copy of those is kept, with the new version's copy beside it as .new to compare and merge. The Health page lists any file that differs from the version installed, so you can see that before you update. A copy of the previous version (data/updates/code-<version>/) and a backup of your data from just before the update stay in data/updates/ [src: app/src/Updater.php]. If Packora shows an error page after an update anyway, that is your way back: copy the files from code-<version>/ over the app's files, then restore the backup on the backup page. Updating from the page needs PHP's sodium and Phar extensions; if one is missing, the Updates and Health pages say so and updates stay by hand. Where Packora is hosted for you, the host updates it and the page says so.

Reaching 4.0 from a 3.x version is the last update done by hand: replace the files as before (config.php and data/ stay, and the database migrates itself on the next page load). From 4.0 on, the Updates page does it.

Starting with sample data

The installer offers Start with sample data: an outdoor-goods brand's SKUs and packaging data to try things on. You can remove it in one click. [src: app/install/index.php:133]. It is ticked by default; untick it to start empty. The sample is added when you open the first page after the installer.

When you are done with it, press the remove button in the sample banner. It asks "Remove the sample SKUs and packaging data? Your own records stay." Only the sample goes: your own records stay, and a sample record that one of your own records uses is kept and named in the report [src: app/src/SampleData.php].

Backups by themselves, and a copy somewhere else

On the backup page (/restore, linked from Settings), Back up once a day by itself is on by default; untick it to stop [src: app/views/restore.php:81]. The first visit at least 24 hours after the last backup starts the next one, after the page has loaded, so no cron job is needed. The newest 14 stay in data/backups/, with credentials left out of them (a restore keeps the live values for those). Back up now takes one straight away.

To keep a copy somewhere else as well, fill in an S3-compatible bucket (Backblaze B2, Wasabi, AWS S3 or your own MinIO): the bucket address (an https endpoint), the region, the bucket name, an optional folder, and the access key ID and secret key. Leave the address empty to keep backups on this server only. The copy goes only to the bucket you set; nothing is sent to Ownware. Once a week Packora reads the newest backup back through the restore's own dry run and keeps the result for the Health page [src: app/src/BackupTargets.php].

On Nginx: keep data/ private

On Apache, the .htaccess that ships with Packora keeps data/ (your backups included), src/, config.php and the other private files off the web. Nginx does not read .htaccess, so add these three blocks to your server block, above the block that passes .php files to PHP (Nginx uses the first one that matches):

location ~ ^/(src|data|bin|tests|controllers|deploy|cron)/ { deny all; }
location ~ ^/views/.+\.php$ { deny all; }
location ~ (^|/)(config\.php|\.demo-mode)$|\.sqlite$ { deny all; }

Then open /config.php in a browser: it should answer 403. Only the template files under views/ are closed, not the folder: the app's own saved-view addresses start with /views/ too, and they must reach it.

A complete server block for a new site (PHP-FPM 8.3; change the name, the folder and the socket to yours):

server {
    listen 80;
    server_name example.com;
    root /var/www/packora/app;
    index index.php;

    location ~ ^/(src|data|bin|tests|controllers|deploy|cron)/ { deny all; }
    location ~ ^/views/.+\.php$ { deny all; }
    location ~ (^|/)(config\.php|\.demo-mode)$|\.sqlite$ { deny all; }

    location = /install/ { rewrite ^ /install/index.php last; }
    location / { try_files $uri /index.php?$query_string; }

    location ~ \.php$ {
        try_files $uri =404;
        fastcgi_pass unix:/run/php/php8.3-fpm.sock;
        include fastcgi_params;
        fastcgi_param SCRIPT_FILENAME $document_root$fastcgi_script_name;
    }
}

The Health page

Health in the menu (/health) shows, one line each, whether the install is looking after itself: the PHP version and extensions, disk space, whether data/ is writable, email, the age of the last backup, the offsite copy, the weekly check, whether an update is waiting, and any file that differs from the version installed. Each line says what to do when it is not fine. The page only reads: it never changes anything and never contacts anything [src: app/src/Health.php].

Connect it to other tools (Zapier, Make, n8n)

Packora sends a webhook the moment something happens and accepts calls to its API, so Zapier, Make and n8n can connect to it without a plugin. The steps, the events and example recipes are in API.md, in the section "Connect it to Zapier, Make or n8n"; the API page of Packora on the store shows the same file [src: app/API.md].

New in 4.1.2: sign-in limits

New in 4.1.2 (security update): Two-step codes are now limited (10 wrong per person or per address in 10 minutes), and wrong passwords count per account as well as per address. An installed app whose database does not answer keeps its web installer closed instead of showing the install form. On Ownware Cloud, opening the app from the console starts a fresh session.

Wrong passwords are counted per address and, since 4.1.2, per account too (the email typed, in any case, stored only as a hash). After 10 wrong passwords in 10 minutes the sign-in at /login answers "Too many attempts — wait a few minutes and try again." for that address, and for that account from any address. A right password clears both counts [src: app/src/LoginRate.php:40].

Wrong two-step codes (for people who turned two-step sign-in on under /security) are counted per person and per address. After 10 wrong codes in 10 minutes the code step answers "Too many wrong codes. Wait ten minutes, then sign in again. A recovery code works in place of the code." even for the right code, and the half-finished sign-in is dropped, so the password is asked again [src: app/src/LoginRate.php:91]. The right code clears that person's count; an address's count runs out ten minutes after its wrong codes. Before 4.1.2 the code step had no limit.

If you are locked out: wait ten minutes, then sign in again with your password and a current code, or one of your recovery codes (each works once). If you lost both the authenticator and the recovery codes, whoever runs the server can turn two-step sign-in off for you in Packora's database (by default the SQLite file in data/, or your MySQL database): UPDATE users SET totp_secret = '' WHERE email = 'you@example.com'; Then sign in with your password and turn it on again under /security.

On Ownware Cloud, opening Packora from the console starts a fresh session [src: app/index.php:57].

The web installer stays closed when the database does not answer: with config.php in place, /install now answers 503 "This app is already installed (config.php exists), but its database is not answering, so the installer stays closed." and writes nothing until the database answers again. Before 4.1.2 a database outage (or a missing SQLite file) showed the install form there, and whoever found it could set up a new administrator. To install again from scratch, remove config.php first [src: app/install/index.php:21].

New in 4.1.3: security update

New in 4.1.3 (security update): no other site can put Packora's pages in a frame; the /mcp address refuses requests from other sites' pages; company sign-in is stricter; and the audit CSV treats one more first character as text. Nothing else changed: no feature, screen, setting or database table, and your data, config.php and data/ are not touched.

No other site can frame these pages. Every page now answers Content-Security-Policy: frame-ancestors 'self' and X-Frame-Options: SAMEORIGIN, so a page on another site cannot show Packora in a hidden frame and trick a signed-in person into pressing a button [src: app/index.php:4]. The installer answers frame-ancestors 'none' and X-Frame-Options: DENY [src: app/install/index.php:4]. If you had Packora framed inside another site of your own, that frame now stays empty; link to it instead.

/mcp refuses other sites. A request whose Origin header names another site gets 403 before anything is read. A desktop or server client sends no Origin and is served as before, and so is a page at Packora's own address [src: app/src/Mcp.php:272].

Single sign-on. The sign-in now uses PKCE (S256), so an intercepted sign-in code is of no use on its own; a sign-in that comes back to a different browser session is asked to start again [src: app/src/Oidc.php:228]. With Google as the provider, a company address needs a Google Workspace account of that same domain (the token's hd claim), and Google must report the address as verified, so a personal Google account made with a work address no longer gets in [src: app/src/Oidc.php:215]. Gmail addresses work as before, other providers are unchanged, and password sign-in is not affected.

The audit CSV. A cell that starts with a carriage return now opens in a spreadsheet as text, as cells starting with = + - @ or TAB already did [src: app/src/AuditLog.php:138].

Docker. The Dockerfile now installs PHP's zip extension, so a Docker install can make the backup with files [src: app/Dockerfile:7].

Switched off means off. A person whose account is switched off (users.active = 0) can no longer sign in, and an open session ends at their next page; before 4.1.3 Packora did not read that flag at sign-in or in the session [src: app/src/Auth.php:9] [src: app/src/Auth.php:18].

Also changed, with no effect on a running install: the corner card on a public demo offers only the purchase, and API.md no longer shows the config example of a product we retired.

← Back to Packora · Quickstart · API · Test run