Self-Hosted Community Phone and Business Directory (Terracotta)
A phone and business directory for a neighbourhood, congregation, school or any other community, that lives on your own website. People add themselves; you approve each one with a click; every listing's owner gets a private link to keep it up to date, and anything nobody has confirmed for a year quietly drops off the list.

Nobody needs an account or a password — not even you. Visitors search as they type, and can filter to businesses or to people.

Somebody adding themselves fills in this form, confirms their email address, and then waits for you to approve it. You get an email with an Approve and a Reject button.
Please test before relying on it
This is shared as-is, with no warranty. It works on my own computers, but your system, settings and software versions may differ, so please try it in a safe setting first. If something doesn't work, you can ask Claude (or another AI coding assistant) to look into it, and I'd appreciate hearing what you found and how you fixed it. You are also welcome to just let me know at support@veered.org, and I'll look into it.
Features
- Business and personal listings. Businesses have a name, category, description and website; personal entries are phone book lines. Each listing can show a work and a home phone and an optional address.
- Live search and filters. All / Businesses / People tabs, a category filter and instant client-side search, with a server-side fallback when JavaScript is off.
- Automatic categories. Categories are inferred from what people type, using an editable keyword map (
$CATEGORY_RULES). Typed categories snap to a known one when possible; otherwise the person's own wording is kept. - Self-service with moderation. Submit, confirm email, moderator review, published. The moderator receives a review link by email; approving or rejecting takes one click.
- Owner edit links. Every approved owner receives a private, unguessable link to update, reconfirm or delete their listing. No login needed.
- Annual reconfirm-or-delist. A year after the last confirmation the owner is reminded, reminded again two weeks later, and after a 30-day grace period the listing is hidden (the name and phone remain, marked "last updated more than one year ago"). Any reconfirmation restores it. All intervals are configurable.
- Data minimisation. Unconfirmed submissions and rejected entries are purged automatically; owner deletion removes the record entirely; expired listings can be purged after a configurable period.
- Right-to-left support. Set
SITE_LANG(e.g.he,ar) and the page direction follows, or force it withSITE_DIR. Layout uses logical CSS properties; names and phone numbers are isolated for correct bidirectional display. Email subjects and sender names are MIME-encoded, so non-Latin site names work in mail too. - Mail your way. PHP
mail()by default, a built-in SMTP client (STARTTLS or implicit TLS, AUTH LOGIN), or a log file for development.
Information for nerds: what you need, how to install it, and every setting
An ordinary website with PHP — which is what nearly every web host sells, including the cheapest shared hosting. No database to set up, no monthly service to sign up for.
In detail, for anyone who wants to check: PHP 7.4 or newer (8.x recommended) with pdo_sqlite; mbstring recommended but optional; SQLite 3.27+ for consistent online backups; Apache with .htaccess, nginx (see docs/nginx.conf), or any server that can deny access to the files listed further down.
Putting it on your website
It works as soon as you upload it. There is nothing to build, no accounts to open and no software to install: almost every web host already runs PHP, which is all this needs.
- Upload the folder to your website, wherever you want the directory to live — for example into a folder called
directory, so it appears athttps://example.org/directory/. Your host's file manager, or any FTP program, will do it. - Open that address in a browser. The directory is already running. It creates its own database on that first visit, and it will tell you, in a red box at the top of the page, the one thing it still needs: who approves new listings.
- Tell it who you are. Rename
config.php.exampletoconfig.php(your host's file manager can rename a file) and, inside it, put your own email address in theMODERATOR_EMAILline and your community's name in theSITE_NAMEline. Save it. The red box disappears, and approval emails now come to you. - Add a listing yourself, as a test, and follow it through: you will get an email asking you to confirm the address, then one with Approve and Reject buttons, and finally a private link for changing the listing later.
That is the whole setup. Everything else on this page is optional.
**If step 2 says it cannot write to data/:** your host has the folder locked. In the file manager, set the permissions of the data folder to 755 (or 775), or see "Shared hosting" below.
Files that must never be served
| Path | Why |
|---|---|
config.php, config.php.example | settings, possibly SMTP credentials |
data/ | the SQLite database, its backup, the development mail log |
lib.php, mailer.php, form.php, header.php, footer.php | include-only |
cron.php | command-line only (it also refuses web requests itself) |
docs/, *.md, dotfiles | not part of the site |
The shipped .htaccess and data/.htaccess enforce this on Apache 2.4. For nginx, use the snippet in docs/nginx.conf. Best of all, move DB_FILE outside the web root.
After installing, check that https://example.org/directory/data/directory.sqlite and https://example.org/directory/config.php return 403 or 404.
The daily job
With WEB_CRON enabled (the default), the first page view each day runs the maintenance job: reminders, expiry, purges and the backup. On a quiet site that can be late, so a real cron entry is more reliable:
15 3 * * * php /path/to/directory/cron.php
php cron.php --force runs it again the same day. php cron.php --today=2027-10-01 simulates a date, which is useful to test the reminder cycle.
Every setting
All settings live in config.php. Optional ones fall back to the defaults shown.
| Setting | Default | Purpose |
|---|---|---|
SITE_NAME | Community Directory | Name shown on the web pages |
MAIL_NAME | SITE_NAME | Name used in email subjects and bodies |
SITE_URL | Public base URL, no trailing slash. Used in emailed links and the cookie path | |
SITE_LANG | en | Page language |
SITE_DIR | '' | ltr, rtl, or empty to derive from SITE_LANG |
TIMEZONE | UTC | Used for all dates |
PHONE_FORMAT | none | none, nanp (US/Canada) or il (Israel) |
MODERATOR_EMAIL | Receives review links | |
MAIL_FROM, MAIL_FROM_NAME | Sender of all mail | |
MAIL_REPLY_TO | MAIL_FROM | Reply-To header |
MAIL_TRANSPORT | mail | mail, smtp or log |
MAIL_SET_ENVELOPE_SENDER | true | mail() only: pass -f MAIL_FROM |
SMTP_HOST, SMTP_PORT | SMTP server | |
SMTP_SECURE | tls | tls (STARTTLS, usually port 587), ssl (implicit TLS, 465) or '' |
SMTP_USER, SMTP_PASS | '' | AUTH LOGIN credentials; never sent without TLS |
SMTP_TIMEOUT | 15 | Seconds |
MAIL_LOG_FILE | data/mail.log | log transport output |
DB_FILE | data/directory.sqlite | Database path |
SQLITE_JOURNAL_MODE | DELETE | DELETE, WAL, TRUNCATE, PERSIST or MEMORY |
DAILY_BACKUP | true | Refresh <DB_FILE>.bak daily |
CONFIRM_INTERVAL_DAYS | 365 | Days until the first reconfirmation reminder |
REMINDER_GAP_DAYS | 14 | Days between first and second reminder |
GRACE_DAYS | 30 | Days after the first reminder until the listing expires |
PENDING_PURGE_DAYS | 14 | Delete submissions whose email was never confirmed |
REJECTED_PURGE_DAYS | 30 | Delete rejected submissions |
EXPIRED_PURGE_DAYS | 0 | Delete expired listings after this long; 0 keeps them |
WEB_CRON | true | Run the daily job from page views |
SUBMIT_LIMIT_PER_HOUR | 5 | New submissions per client IP per hour; 0 disables |
BLURB_MAX | 600 | Maximum description length |
$CATEGORY_RULES | see file | Ordered category => [keywords] map; first match wins |
$CATEGORY_EXTRA_KEYWORDS | example set | Community-specific keywords merged into the map |
The shipped $CATEGORY_EXTRA_KEYWORDS is an example for a Jewish community (it adds transliterated Hebrew and Yiddish trade terms and a Judaica category). Replace it with your own community's vocabulary, or delete it.
Mail delivery
- **
mail** works on most shared hosts. KeepMAIL_SET_ENVELOPE_SENDERon so the envelope sender matchesMAIL_FROM; otherwise many hosts send with the account's system address and receivers may file the mail as spam. TheMAIL_FROMdomain should publish SPF (and ideally DKIM and DMARC) records that authorise your server. - **
smtp** sends through a mailbox or relay provider. Hosts often sign DKIM only for authenticated SMTP, so this usually gives the best deliverability. The client verifies the server's TLS certificate and refuses to send credentials over an unencrypted connection. - **
log** appends each message toMAIL_LOG_FILEand sends nothing. Use it only for development; the log contains personal data and edit links.
Mail errors are written to the PHP error log and never shown to visitors.
When the host will not let it write
On many shared hosts PHP runs as a common web user (often nobody or www-data), not as your account. That user must be able to write the database file, and with the default journal mode also create files in its directory (SQLite writes a temporary -journal, or -wal/-shm files in WAL mode, next to the database).
Pick one:
- Preferred: make only the data directory writable by the web user, e.g. through your host's permission tool or a group (
chgrp www-data data && chmod 2770 data). - If the only option is making a directory world-writable, do not do that. Instead pre-create the files and make just those writable, and set
SQLITE_JOURNAL_MODEtoMEMORYso SQLite never needs to create a journal file:
sh touch data/directory.sqlite data/directory.sqlite.bak chmod 666 data/directory.sqlite data/directory.sqlite.bak
MEMORY keeps the rollback journal in RAM. It is safe against concurrent requests but a crash in the middle of a write can corrupt the database, so keep the daily backup enabled and copy it off the server from time to time. Files with mode 666 on a shared server may also be readable by other accounts' scripts; prefer option 1 wherever the host allows it.
Symptoms of this problem are "attempt to write a readonly database" or "unable to open database file" in the PHP error log.
Security
- All SQL uses prepared statements; all output is escaped with
htmlspecialchars. - Edit, confirmation and moderation links carry 160-bit random tokens. Confirmation and moderation actions need a button press (POST), so mail scanners that pre-fetch links cannot confirm, approve or reject anything. Moderation tokens are cleared once used.
- Forms are protected against CSRF with a SameSite=Strict, HttpOnly double-submit cookie.
- Responses send a Content-Security-Policy with per-request script nonces,
X-Frame-Options: DENY,X-Content-Type-Options: nosniffandReferrer-Policy: no-referrer, so private links do not leak through the Referer header. - Email headers are built from single-line, MIME-encoded values, preventing header injection.
- Submissions are rate-limited per IP; IPs are stored only as salted hashes for an hour. A hidden honeypot field silently discards simple bots.
- Anyone holding an edit link can change that listing. Owners should treat it like a password; they can delete the listing at any time.
Privacy
This software publishes names, phone numbers and optionally addresses. The operator of each installation is responsible for obtaining consent, publishing a privacy notice, responding to access and deletion requests, and complying with the data protection law that applies to them (for example the GDPR).
The software is designed to help: every listing is added by the person it describes and confirmed through their own email address; email addresses are never shown publicly; owners can edit or delete their listing without contacting anyone; unconfirmed and rejected submissions are purged automatically; and the annual reconfirm-or-delist cycle keeps stale personal data from lingering. Consider setting EXPIRED_PURGE_DAYS so that expired listings are eventually deleted as well.
License
MIT. See LICENSE.