Deployment#
Scheduled tasks#
Without them the system does not work properly. Tasks are presenters in
app/UI/Cron/, triggered by an HTTP request with a token.
| Group | What it does | How often |
|---|---|---|
| E-mail queue | sends | every few minutes |
| Expiry | warnings and deletion of expired adverts and listings | daily |
| Recalculations | category counts, prices | 10–15 min |
| Saved searches | sends alerts | continuously or daily |
| Payments | follow-up invoicing, evaluation | continuously |
| Invoice scheduler | recurring invoicing | daily |
| Elasticsearch | sync and a nightly reindex | 10 min / daily |
| Sitemaps | generation, including the e-shop ones and the index | daily at night, the index last |
| Notification cleanup | /cron/system/notification/clean-orphans — detaches notifications whose target record no longer exists (they stay as text without a link; otherwise they would lead to a 404 in the administration); each run checks the next 500 older notifications (the position is kept in the cache; after a cache flush it starts from the newest); runs itself at most once per 20 h |
daily |
| Notification retention | /cron/system/notification/purge-read — deletes read notifications older than notification.readRetentionDays (default 90 days), never unread ones |
daily |
| Auth log | /cron/system/auth-log/purge-old — deletes old login records (default 90 days) |
daily |
| E-mail queue retention | /cron/system/email-queue/purge-old — deletes processed e-mails older than email.queue.retentionDays (default 30 days, Settings → E-mail); message bodies must not stay in the database forever |
daily |
| Uploader temporary files | /cron/system/dropzone-temp/clean — removes files and folders older than 24 h from temp/dropzone (unfinished uploads) |
daily |
| Account deletion | /cron/system/user/anonymize-deleted — anonymises accounts whose grace period after a deletion request has passed (account.deletion.graceDays, default 7 days); without it requests just wait |
daily |
| Logs | /cron/system/log/purge-old — deletes Tracy error dumps older than log.retentionDays (default 30) and rotates *.log above log.maxSizeMb (default 50) to .log.1 (3 generations); Settings → Cron |
daily |
What to schedule before going live
Besides the module jobs (e-mail queue, expiration, recounts, Elasticsearch, sitemaps), always schedule these five
housekeeping jobs under /cron/system/…: notification/clean-orphans, auth-log/purge-old, email-queue/purge-old,
dropzone-temp/clean, log/purge-old, plus user/anonymize-deleted (account deletion). Each is called with ?token=<cron.token>.
notification/clean-orphans, dropzone-temp/clean, log/purge-old and user/anonymize-deleted guard themselves against
running more than once per 20 h; auth-log/purge-old and email-queue/purge-old do not, so schedule them only daily.
Without scheduled tasks the problems surface only after weeks
Adverts never expire, counts stop matching, emails pile up. Nothing reports it. After deploying, check the tasks actually run.
Nothing ever executes in www/uploads/
Uploaded files live in the public tree and keep their extension. The application
rejects dangerous extensions (php, phtml, phar, cgi, sh, .htaccess …) in any
part of the name, but the web server is the second line of defence.
Apache reads the versioned www/uploads/.htaccess (PHP engine off, script
extensions denied). It needs AllowOverride All (or at least FileInfo Options Limit)
for the site directory.
nginx ignores .htaccess. Add this to the server block before the generic
location ~ \.php$:
After deployment verify that an uploaded test.php.txt in uploads/ is served as
text, not executed.
Concurrency is handled by a lock
Two instances of the same task will not run. But do not schedule them needlessly often — a long task blocks the next one.
Deploying code#
Code reaches the server in a single step: the script deploy/deploy-demo.sh, run by hand
from your own computer against your own server. A push to main deploys nothing by itself (the workflow
.github/workflows/deploy-demo.yml is still in the repository, but it does not run). The script
takes exactly the content of the commit (git archive HEAD), installs composer dependencies
without dev packages, mirrors the code to the server with rsync over SSH and clears the
cache in the same SSH session right away.
deploy/deploy-demo.sh # deploys the current commit of the main branch
deploy/deploy-demo.sh --dry-run # only shows what rsync would upload and delete
deploy/deploy-demo.sh --allow-dirty # allows uncommitted changes (still only the commit is deployed)
deploy/deploy-demo.sh --skip-tests # emergency only: deploys a commit without green tests
Access to your server (user, path, key) comes from deploy/deploy-demo.local.env, which is not
in git (template deploy/deploy-demo.local.env.example); replace the pinned server key
(KNOWN_HOSTS_LINE) in the script with your own server's key, and likewise the site address the
smoke test runs against. The script refuses to run outside the main branch and with uncommitted
changes.
Only a commit that passed the tests is deployed: after a green run on a clean tree,
tests/run.sh T1 (or a higher level) writes <commit> <level> to tests/.last-run, and without
an entry for the current HEAD the script stops before uploading anything. The test levels are
described in Tests and tools.
| Step | Implementation |
|---|---|
| swapping files | rsync -rlptz --delete --delay-updates — files are uploaded aside and renamed only at the end, so the code swap takes a fraction of a second instead of the whole transfer |
| what is never overwritten or deleted | deploy/rsync-exclude.txt: config/local.neon, config/Shared/local.neon, config/debug-mode.flag, uploads, uploads_thumbs, feeds, sitemaps, private/pdf, private/eshop, temp, log, var |
the cache in temp/ |
only files are deleted (find temp -mindepth 1 -type f -delete), directories stay |
| SQL migrations | bin/migrate.php after clearing temp/ and before clearing Redis; see SQL migrations |
| Redis | the keys app:cache:q:* (query cache) and app:cache:route:* (the route table and slug maps) in database DEMO_REDIS_DB (default 0, see Several installations on one server); sessions and tag versions are not deleted |
| the server key | pinned in the script (KNOWN_HOSTS_LINE, StrictHostKeyChecking=yes); ssh-keyscan is not used, so a spoofed server never receives the deploy key. If the key does not match, ssh/rsync stops before sending anything |
| smoke test | after deployment the script runs php tests/Manual/Verify/demo_smoke.php <site-url> (read-only, no sign-in, no writes): the home pages of all languages, the listing of every module and one detail from it, the administration sign-in page, robots.txt and the sitemap it points to. A failure = a status other than 200 (after redirects), an error page or Tracy; slow pages are only reported. A missing sitemap is only a warning (a cron job generates it) |
| result | when the smoke test fails, the code is already deployed, but the script ends with exit code 4 and reports the deployment as failed; a failed migration returns the migration's code |
| the first request | takes about 9 s (a cold cache), the next 0.4–1.1 s; the smoke test also warms the site up, so the script pays for it, not a visitor |
The first requests after clearing the cache can briefly fail
The smoke test runs right after the cache is cleared, while the DI container is still
compiling, and the first requests can briefly return 500 or 503. A single failure therefore
does not yet mean a failed deployment: run the smoke test again
(php tests/Manual/Verify/demo_smoke.php <site-url>). Look for the cause in log/ only
when the error persists.
Delete Redis keys always with xargs -r -d '\n'
Keys carry class names with backslashes (app:cache:q:App\UI\…) and the default
xargs drops them from the arguments. The earlier redis-cli --scan … | xargs redis-cli
del therefore did not find the key and deleted 12 of 4,457; the cache merely expired after
the TTL. Correct:
After deleting verify the count (only fresh keys should remain after deployment). The same applies to every manual deletion.
Uploading and clearing the cache must be ONE step
These used to be two steps and between them the new code ran with an old compiled DI
container: every newly injected dependency threw Typed property … must not be accessed
before initialization and RobotLoader: Unable to create …. It lasts seconds and fixes
itself, but it is visible on the pages. A deleted directory under a running request
threw a RobotLoader error, hence only files are deleted.
Deployment runs the SQL migrations itself
After uploading the files and clearing temp/, the script runs bin/migrate.php; if a
migration fails, deployment ends with an error. For the procedure, the rules and the first
run see SQL migrations.
The server local.neon keeps its own enabledServices
A new cached service must be added there too, otherwise it is read from the database (one extra query per request). See Cache.
Log rotation#
Without rotation the log/ directory grows and nobody reports it; debug.log and
auth.log grow the most. The sources that wrote a line on every request were removed (route whitelists on every router
build, INTERNAL_AUTH no_token on every internal read, "Dynamic routes" and "Lazy warmup
triggered" in the translators); RBAC decisions, bad tokens, errors and exceptions are still
logged.
Set up logrotate
Daily, 3 compressed generations, earlier at 200 MB; exception dumps *--*.html|md older
than 14 days are deleted. Without rotation the log grows until the disk is full.
SQL migrations#
The schema is maintained by hand-written repeatable scripts in sql/. On deployment
bin/migrate.php runs them on the database — nothing is run by hand any more and nobody has
to remember the order.
What deployment does#
After uploading the files and clearing temp/, the SSH step runs bin/migrate.php (PHP 8.5,
else 8.4, else the default php) and only then clears the Redis cache.
- It acquires the database lock
coolcms_sql_migrations(GET_LOCK, waits 30 s) — only under it does it read the record, so two concurrent runs never run the same file twice. - It reads which files have already run on this database (the table
cms_system_sql_migrations:filename,checksum= SHA-256 of the content,applied_at). - It verifies the checksums of the already applied files. If any file's content differs from the record, it prints its name and exits with code 1 — it runs nothing.
- It picks the pending migrations — files
sql/YYYY-MM-DD_NN_description.sqlthat are not in the record — and runs them one by one, in file-name order. - After each successful file it writes it to the record. A file that is in the record but missing from the folder is only reported with a warning.
The selection is done by App\Core\Utils\Helpers\SqlMigrationPlanner:
| File | Is it run? |
|---|---|
YYYY-MM-DD_NN_description.sql |
yes, once |
*_rollback.sql |
never — a rollback is run by hand |
*_dev_pii_anonymization* |
never — development scripts |
anything else in sql/ (another name shape) |
no |
The code is uploaded BEFORE the migrations
The order of steps is: upload files → clear temp/ → migrations → clear the Redis cache.
Between uploading the code and the end of the migration the new code therefore runs briefly
over the old schema. A migration that adds a column the new code reads (formerly called
"before the code") causes errors on the affected pages for that time. For a zero window,
split the change into two deployments: first a migration the old code does not notice, and
in the next deployment the code that uses it.
The first run and the baseline#
On a database where the record does not exist yet it is impossible to find out what was
run by hand earlier. Without the record bin/migrate.php therefore exits with code 1 and only
prints what it would take as done — it marks nothing silently. The first run is started by
hand with --baseline:
It creates the table and every migration whose name sorts before 2026-10-02_08 (the
constant SqlMigrationPlanner::BASELINE) is only recorded as done — not run. From
2026-10-02_08 on, everything is run. On a database where the record already exists,
--baseline is not used.
A new server: the first run not from the deploy script
Deployment does not pass --baseline. On a new server without the record the migration
step would end with an error — run the first run there by hand and only then deploy.
The database must be brought up to date by hand before the first run
What lies before the baseline will never run on its own. A database that came from an
older dump must therefore have all older migrations applied by hand before deployment
runs for the first time. This includes 2026-10-02_06_remove_google_settings.sql — it falls
under the baseline, so on a database where it has not run yet, run it by hand after
deploying the code.
Procedure: see what deployment will run#
It lists the pending migrations in run order (or "no pending migration"). It writes nothing
to the database, not even the record; on a database without the record it says how many older
files would be taken as done on the first run. Without the switch the migrations are run; the
command can also be run by hand (for example after fixing a failed migration). It reads the
connection from parameters.mariadb in the configuration (config/Shared/parameters.neon,
overridden by config/Shared/local.neon and config/local.neon).
What happens on an error#
| Situation | Behaviour |
|---|---|
| a migration fails | the script prints the file and the error, exits with code 1, runs no further migration and does not record the failed file; deployment ends with an error |
| a migration fails in the middle of a file | structure changes (DDL) are not rolled back; the file is run again from the start on the next deployment |
| the file ran but writing the record failed | the script ends with an error; the file runs again next time |
| migrations are being run elsewhere at the same time | the script waits 30 s for the lock coolcms_sql_migrations, then ends with an error |
| the content of an already applied migration changed (the checksum does not match) | the script prints the files, exits with code 1 and runs nothing; deployment ends with an error |
| the record does not exist on the database | without --baseline it exits with code 1, see The first run |
| PHP is not available on the server | the migrations are not run and deployment ends with an error |
After every run that executed at least one migration the script itself deletes the query cache
and the route cache in Redis (<prefix>cache:q:*, <prefix>cache:route:*, default prefix app:;
host, port and prefix are read from the configuration). Migrations change data outside saves
through the managers, so no tag is invalidated and services with a longer cache lifetime (see
Lifetime per service) would keep old content for up to an
hour. Sessions and tag versions are not deleted. Without Redis (or with storage switched off)
the script only prints that the cache will expire by itself. A run with no pending migration
does not clear the cache.
A failed migration does not prevent the Redis cache from being cleared; only the whole step
ends with an error so that somebody notices. After the fix, deploy again (or run
php bin/migrate.php).
Rules for writing a migration#
| Rule | Why |
|---|---|
the name YYYY-MM-DD_NN_description.sql, a rollback YYYY-MM-DD_<higher NN>_description_rollback.sql |
rollbacks have prefixes from 99 downwards and the runner skips them |
a migration is repeatable (IF NOT EXISTS, INSERT IGNORE, NOT EXISTS) |
a failed file is run again from the start; DDL commits implicitly in MariaDB, a transaction guards nothing |
no DELIMITER statement |
the file is sent to the server whole (multi_query); DELIMITER is a client command and does not work there |
the prefix YYYY-MM-DD_NN is unique |
the order follows the whole file name; two scripts with the same prefix are ordered by the rest of the name, which is chance |
| do not edit an already deployed file | the runner compares the checksum and on a mismatch stops the whole deployment. The fix is a new migration; a change with no effect on the database (a comment) is accepted deliberately with php bin/migrate.php --accept-changed — it only rewrites the checksum in the record and runs nothing again |
files with personal data do not belong in sql/ |
rollbacks with original data (*_dev_pii_anonymization*) are outside the git index and in .gitignore; the runner never runs them either |
tests/Unit/Sql/SqlMigrationPrefixTest.php fails on a new prefix collision (historical
collisions are in the ALLOWLIST) and tests/Unit/Sql/SqlMigrationPlannerTest.php guards the
selection, the order, the baseline and the reporting of changed and missing files.
Only the number expresses a dependency between migrations
The order is driven by the name alone. If migration B has to run after migration A, give it
a higher NN — nothing else expresses the dependency.
Migrations of 1–2 October 2026#
In the order the runner runs them (by name). _01 to _07 fall under the baseline — on a
freshly created record they are not run, only recorded.
| File | Note |
|---|---|
2026-10-01_01_blog_article_news_region.sql |
a region on articles and news |
2026-10-02_01 … _05 |
external connections: the table, Api privileges, the admin page, recaptcha_v3, four Google types |
2026-10-02_06_remove_google_settings.sql |
deletes the old settings rows; after the code, by hand (see above) |
2026-10-02_07_admin_discussion_message_reports.sql |
the admin page of reported posts |
2026-10-02_08_response_mark_read_pages.sql |
pages 2011 and 2012, routes — the first one the runner runs by itself |
…_09_response_mark_read_privilege.sql |
the markReadByKey keys |
…_10_response_mark_read_email_button.sql |
the button in the email templates |
…_11_response_read_key_column.sql |
the read_key column on responses |
…_12_response_receiver_role.sql |
the role receiver; replaces the grant from _09 |
…_13_rating_save_reply_receiver.sql |
Rating:saveReply from owner to receiver |
…_14_message_thread_owner_receiver.sql |
roles over message threads |
…_15_message_read_receipts_setting.sql |
the read-receipts switch |
…_16_message_thread_participants_admin.sql |
managing thread participants in the administration |
…_17_demo_admin_password_repair.sql |
restores the administrator password on the public demo; runs only on its database and only when the password is empty |
…_18_admin_roles_page_href_lowercase.sql |
lower-case addresses of roles, permissions and groups |
Find the rollback of a migration by the description in its name:
…_<higher number>_description_rollback.sql (for this batch 2026-10-01_91 and
2026-10-02_82 to _99).
After applying by hand, clear temp/cache and flush Redis
Deployment clears the cache itself. When you run a migration by hand (a rollback, a
fix), RBAC, the router, the pretty-URL map and Doctrine metadata stay cached. Scripts that
add pages or routes do not invalidate the route-table cache (the key
app:cache:route:table) — without clearing, the new route is not loaded. For deleting keys
see above.
Server operating requirements#
| What | Requirement | Why |
|---|---|---|
PHP-FPM memory_limit |
at least 256M; for the full 40 Mpx image cap about 320M | compiling the DI container after deployment peaks at ~128 MB in a web request — with 128M the first requests failed with 500. An image is checked by a memory estimate against the free part of the limit; with 128M only images up to ~12–15 Mpx pass |
CLI memory_limit |
higher than FPM, or -1 |
compiling in CLI (bin/migrate.php, a different container) needs ~200 MB; a bin/*.php crash at 128M is not an application bug |
ffmpeg / ffprobe |
installed | video is verified by content before it is stored; without ffprobe video is rejected (logged to log/assets.log), images keep working |
| Redis | Restart=always in systemd and monitoring |
an outage no longer brings the site down — see below — but the query cache and ES run slower without it |
logrotate |
a second safeguard next to the log/purge-old cron |
see Log rotation |
Sessions survive a Redis outage
When Redis fails while reading a session, TieredTtlSessionHandler switches that request's
sessions to files in temp/sessions (the sessionTtl.fallbackDir option) and the next
request tries Redis again. One line per hour goes to warning.log, and a line to
info.log when Redis is back. Sign-ins made during the outage are lost once Redis returns.
See Cache.
The webroot is not derived
App\Bootstrap sets %wwwDir% explicitly to <root>/www. Before, nette/bootstrap derived
it from the outermost stack frame, so a script started from elsewhere (a harness, a runner)
built paths one level off and wrote into www/www/. www/.htaccess returns 403 for an
existing file under www/www/.
Several installations on one server#
One server can run several CoolCMS installations, for example a live site and its public demo.
Each installation has its own database, files, temp/ and logs. Redis, Elasticsearch and
browser cookies are shared, though, until they are separated in local.neon. Redis keys carry
no installation id: jwt:denylist:…, ratelimit:…, app:cache:…, Contributte.Storage:… and
sessions under the bare session ID are the same for every installation.
Check local.neon before deploying another installation onto the same server
With the same configuration the installations overwrite each other's cache and sign each other's users out. They invalidate each other's API tokens, add up their request limits and write into the same search indexes: the document with id 5 of one installation overwrites the document with id 5 of the other. None of this is reported; it only shows as odd behaviour.
Setting in parameters |
Default | What it separates | When to change it |
|---|---|---|---|
redis.database |
0 |
the whole Redis content: cache, sessions, JWT deny-list, request limits, Elasticsearch state | every installation with Redis on gets a different number (Redis has 16 by default, 0–15) |
elasticsearch.indexPrefix |
cms |
indexes and aliases <prefix>_<source>, keys es:<prefix>:… in Redis |
every installation with Elasticsearch on; lowercase letters and digits only |
sessionName |
coolsoftcms |
the session cookie name | installations on the same second-level domain (company.com and demo.company.com), even without Redis |
sessionCookieDomain |
domain |
domain sends the session cookie to the whole second-level domain including subdomains, null only to the current host |
null is enough for the second and further installation on the same domain |
A second installation on the same server and domain has, for example, this in
config/Shared/local.neon:
parameters:
redis:
database: 1
elasticsearch:
indexPrefix: 'company'
sessionName: 'companyweb'
sessionCookieDomain: null
The first installation can keep the defaults. Changing redis.database or indexPrefix on an
installation that is already running means an empty cache, all users signed out and a new
reindex of all Elasticsearch sources.
Do not use the Predis prefix option
Predis can add a prefix to every key (redis.connection.default.options.prefix), but it does
not add it to the SCAN pattern. Clearing the own cache would then find nothing, and
deleting by KEYS would add the prefix twice. Installations are separated only by the
database number.
Installations without Redis and Elasticsearch#
A smaller site needs neither Redis nor Elasticsearch. With this configuration the installation does not connect to them at all, so it does not affect the shared Redis and cluster of the neighbouring installations:
parameters:
redis:
storage: false # Nette cache in files under temp/
sessions: false # sessions as ordinary PHP sessions
jwt:
useRedis: false # token deny-list in the database; request limits follow it
elasticsearch:
enabled: false
cache:
config:
enabled: false
routerEnabled: false
cache.config.enabled and routerEnabled are off by default and are listed to be sure. With
both off, neither tag versions nor cache statistics are written into Redis. sessionName is
needed here too, because it is a browser cookie, not Redis.
Sessions in files: every site its own directory
Without Redis, PHP stores sessions in session.save_path. If two sites share one
directory, the clean-up of old sessions of the site with the shorter lifetime deletes the
other site's sessions too. Check that every PHP-FPM pool has its own
php_admin_value[session.save_path]:
Deployment and clearing the cache by hand#
| Where | How it knows the database number |
|---|---|
deploy/deploy-demo.sh |
the DEMO_REDIS_DB variable in deploy/deploy-demo.local.env (default 0); deletes with redis-cli -n <number> |
bin/migrate.php |
reads redis.database from the configuration by itself |
| clearing by hand | always with -n <number>: without it redis-cli works on database 0, i.e. another installation |
Each installation sets up its scheduled tasks separately, with its own token. This includes the Elasticsearch sync and nightly reindex.
Shared server capacity#
| What | Why to watch it |
|---|---|
| OPcache | installations on the same PHP-FPM version share one OPcache. One installation has about 15–20 thousand PHP files including dependencies, plus compiled templates and the container. For two installations raise opcache.max_accelerated_files to at least 65407 and opcache.memory_consumption accordingly, otherwise OPcache keeps filling up and resetting |
| Redis memory | maxmemory applies to the whole Redis. Use the noeviction policy: allkeys-lru would also evict sessions and the token deny-list when full |
| Elasticsearch heap | the indexes of all installations live in one cluster and one heap |
| PHP-FPM | every site has its own pool and its own pm.max_children; the sum of worker processes must fit into the server memory |
Checking the separation#
redis-cli INFO keyspace # every installation has its own db<number> line
curl -s -u elastic 'http://127.0.0.1:9200/_cat/aliases?v' # aliases start with the installation prefix
In the browser, sign in to both installations and switch between them. Neither may sign the user out, and each must have a session cookie with its own name.
PHP 8.5#
The code is verified on PHP 8.4 and 8.5: php8.5 -l over the whole of app/ (5,821 files)
without errors, composer why-not php 8.5.0 without an obstacle, and after the fix below a
click-through of the front and the administration passed. Unit tests have not yet been run
under 8.5 (the local machine runs 8.4).
Redeclaring an injected property in a trait takes down all pages of the presenter
AddAdvertFormTrait (Bazaar, Comcat) declared public CurrencyManager $currencyManager;
without #[Inject], while BasePresenter inherits the same property with #[Inject].
PHP 8.5 returns the descendant's declaration (without the attribute) on reflection, so
Nette did not fill it and every page of AdvertPresenter fell on must not be accessed
before initialization. On 8.4 it worked. The fix: remove the declaration from the trait. A
scan of the whole of app/ found no other case.
The rule: a trait or a descendant must not redeclare a property the ancestor injects. If you need it, use the inherited one.
When you suspect “PHP 8.5” first rule out a stale container
The same message also arises on deployment with an old DI container (see above). They are
told apart by it disappearing after temp/ is cleared; a genuine redeclaration stays.
Production settings#
| Item | Setting |
|---|---|
| Debug mode | off |
| Error display | off, log instead |
| Cache | on |
docs/sql/ |
not reachable from outside |
| Gateway credentials | in local.neon only |
www/uploads/ |
no script execution |
The debug panel prints the configuration, credentials included
Left on in production, the keys to payment gateways and other services end up in the page source. Verify it is off.
How debug mode is decided#
The production default is off; it is switched on explicitly.
App\Bootstrap::resolveDebugMode() decides in this order:
- the
NETTE_DEBUGenvironment variable —1,true,on,yesturn it on, anything else including0andfalseturns it off, - the existence of the
config/debug-mode.flagfile (not in the repo; on the dev server you create it withtouch), - otherwise off.
The environment variable overrides the flag file
When NETTE_DEBUG is set to anything outside the permitted four values, the flag file
is not read at all. On a machine where the variable is set by accident, touch will
therefore not help.
Building assets#
Use the global sass, not npx sass
Different versions producing different output. The difference shows as small layout shifts nobody connects with the deployment.
Deploying onto an existing database#
Upgrading an older installation inherits more than data — it inherits schema
defects nobody has seen for years. Run the
Database check before deploying; it is read-only
SELECTs and safe against production.
The first query matters most: it lists *_texts translation tables missing their
composite primary key. Without it the database accepts duplicate translations,
and every further seed run adds more.
Automated checks will not find this
SchemaValidator::validateClass compares columns and types, not indexes — a
missing primary key passes it. The only ways to find out are this query, or
the site showing different text than the admin does.
The chapter covers what to do about a finding, with the fix scripts to copy.
Post-deployment checks#
The smoke test in the script only verifies that pages render. After deployment also go through:
- The home page and signing in.
- One administration listing and one form.
- A test order all the way to dispatch.
- The email queue — is it sending?
- The error log — anything new?
- Database check — when deploying onto an existing database, run it again to verify the migrations did not introduce duplicate translations.
- If another installation runs on the server, check the separation: its own Redis database, index prefix and session cookie name.
A test order is the best check
It goes through the cart, payment, stock, invoicing and email at once. One pass tests more than an hour of clicking around screens.