lodgely is a lightweight, open-source lead intake hub for small teams. It collects leads from CSV files, email (mock and real IMAP), webhook, Google Sheets fetch, Meta Lead Ads (API), OpenFlow forms, and manual entry, normalizes them into a single schema, and gives reviewers a clean inbox to prioritize, deduplicate and forward.
lodgely is intentionally not a CRM. No deals, no pipelines, no sequences, no forecasts. It is the layer before a CRM — the place where incoming leads from many sources stop being scattered and start being actionable.
- Who it is for
- Features
- What's intentionally out of scope
- Tech stack
- Quick start (Docker)
- Quick start (local PHP, no Docker)
- CSV import
- Architecture at a glance
- Privacy & GDPR notes for self-hosters
- Configuration reference
- Roadmap
- Ethical use
- License
Deeper detail lives in docs/: Features ·
Architecture · Configuration reference ·
Privacy & GDPR · Roadmap ·
Changelog.
- Small businesses that want to handle their own lead intake without a €100/seat CRM.
- Agencies that handle leads for several client brands. One lodgely
install hosts many
client_names; scoped logins let each client see only their own leads. - Inhouse marketing teams that just need a sane shared inbox for the leads coming out of forms, lists and inboxes.
If you need a full sales pipeline, lodgely is the wrong tool. If you need a clean place to triage leads before anything else happens, you are at home.
The full write-up for every bullet below — including config flags, edge cases and gotchas — lives in docs/FEATURES.md.
Inbox & review
- 📥 Unified lead inbox — filters, saved views, column picker, sortable columns, pagination.
- 🧹 Automatic duplicate detection on normalized email/phone.
- 📝 Side-panel review — status, priority, notes, audit trail, Meta ad attribution. Laid out so what you do (reach out, set status, write a note) stays open at the top and what you only read (ad attribution, AI, older activity) collapses. The activity log switches from "3 weeks ago" to an exact timestamp once an entry is more than a day old.
- 🎯 Outcome tracking — beyond the intake states (New / Reviewed / Incomplete / Duplicate), leads carry what came back: Pending, Offer sent, Successful, Declined, No reply, Forwarded. Status is a pill row in the side panel (not a dropdown) and fills itself in for the first two steps: opening a lead marks it Reviewed, the first outreach toggle marks it Pending. Everything after that stays a deliberate click.
- 💬 One-click note phrases — "Mailed them", "Declined — price", "Successful — booked" and friends drop into the note box. Declined/Successful reasons highlight the matching status pill as a nudge; there's deliberately no bare "Declined"/"Sent offer" chip, since the status pill next to it already says that precisely — the phrase adds the why.
- 📞 One-click
tel:/mailto:links on the phone/email in the lead side panel — no copy-pasting a number to reach out. - ✅ Outreach state (Qualified / Called / Mailed) toggles with audit trail; clicking a call/email link nudges (highlights) the matching pill as a reminder to confirm the outcome yourself, since opening a dialer or mail compose window doesn't mean the call connected or the email was sent.
- ☑️ Bulk actions — status/priority change or delete across selected leads.
- ⬇️ CSV / NDJSON export of the filtered inbox, streamed and audited.
- 🔖 Saved filters & a starred default view per user.
- 🧱 Per-user column picker, including auto-discovered custom-answer columns.
Lead intake
- 📂 CSV importer (10k rows/file, common header aliases recognized).
- ✉️ Email importer — mock generator, or a real IMAP backend on a schedule.
- 🔗 Signed-token webhook importer for any HTTP client.
- ✍️ Manual entry for phone calls and walk-ins.
- 📊 Google Sheets recurring lead source with auto column-mapping and idempotent re-fetches.
- 📥 Meta Lead Ads (API) recurring lead source, idempotent on the Meta lead id.
- 🌊 OpenFlow recurring lead source — add as many OpenFlow sources as you need (any mix of forms and self-hosted installs), each pulling submissions into a specific client with its own operator-defined field mapping. Idempotent on the submission id, scoped per source so two sources can never dedupe against each other's leads. Authenticates with a read-only OpenFlow API token (recommended) or an email/password login.
Users & access
- 👥 In-app user management, client-name scoping, self-service password reset links. Deactivation and password changes invalidate existing sessions immediately.
- 🔐 Two roles:
operator(sees everything) andclient(scoped to theirclient_name). - 🏷️ Client type presets (B2B / Jobs / B2C / Individual intent) — swaps the word "Lead" for "Applicant", "Prospect" or "Inquiry" on that client's own inbox, since not every client is sourcing B2B sales leads. Also relabels a handful of outcome statuses that read oddly outside B2B sales (Jobs: "Offer sent"/"Successful"/"Declined" → "Invited"/"Hired"/"Rejected"; Individual intent: "Successful" → "Enrolled") — most statuses stay as-is. Set per client user in
/users; defaults to B2B (unchanged wording) when not set. Free-form label editing is not offered — presets only. - 👤 Per-user profile page (name, email, password, language, theme).
- 🔑 Public password-recovery flow, enumeration-safe.
Reporting & AI
- 📈 Operator
/reportingdashboard — KPI cards, trend charts, campaign breakdown, ad-spend ingestion from Meta + Google Ads (live or mock adapters). A per-client pill filter (All clients / Client A / …) narrows the whole dashboard to one client — lead figures byclient_name, ad spend by that client's campaigns. - 🔌 Multiple Meta/Google Ads connectors — beyond the single default connector,
/settings/ad-platformslets an operator add a dedicated Meta and/or Google Ads connector per client (its own ad account, token/OAuth). That client's ad spend and creative rows are then reported to them alone on/my-reportsand scheduled report emails, instead of the shared default connector's data. A connector can also be scoped to one brand within an ad account that serves several businesses — by Google Business Name asset id or Meta Page id, never the customer-facing name. Each client connector must carry its own ad account id / customer id — unlike the shared default connector, it does not inherit those from.env, so a half-configured connector cannot quietly re-import the default account's spend under a client's name. - 🎨 Creative performance overview on
/reporting— top ads and age/gender segments from Meta, top keywords and ads from Google Ads, ranked by spend with clicks, leads and CPL per row. Fetched alongside the campaign metrics (same daily pull and "Fetch data now" button), aggregate numbers only. - 📊 Custom client reporting views, assignable per client, with a Live/Hidden toggle and a
/my-reportsclient tab. - 🤖 AI summaries & lead qualification (optional, off by default) — OpenAI-compatible or Ollama, operator-reviewed drafts.
- 📨 Scheduled/one-off client report emails, mobile-responsive HTML.
- ✉️ In-app SMTP configuration that overrides
.envmail settings at runtime.
Ops
- 🧾 Full audit log of lead lifecycle changes.
- 🗑️ Retention-aware (
retention_until) with an opt-in GDPR purge command. - 💾 Backup & recovery — one-click
.zipbackups, UI restore, and matching artisan commands. Optional passphrase encryption of the database dump inside the archive, plus optional retention pruning. - 🌙 Dark/Light mode, persisted per user.
- 🌍 i18n — English and German, persisted per user.
- 🧪 One-click demo data load/unload for a scoped, reversible demo dataset.
Architecture seams are reserved but not yet implemented — see docs/ROADMAP.md:
- Multi-tenancy (
tenant_idexists everywhere; only the default tenant is wired)
- PHP 8.4, Laravel 12
- Livewire 3 + Alpine.js, Blade-first server rendering
- Tailwind CSS 4
- PostgreSQL 16
- Caddy 2 (reverse proxy)
- Database-driver queues (no Redis required)
- Docker Compose for local + small-VPS deployments
- Google Sheets v4 REST API + OAuth 2.0 (optional; for the Sheets import source)
A typical lodgely install runs comfortably on ~512 MB RAM.
# 1. Clone and configure
git clone https://github.com/vidual-labs/lodgely.git
cd lodgely
cp .env.example .envOpen .env and set at minimum:
| Key | What to set |
|---|---|
APP_URL |
Your public URL, e.g. https://lodgely.example.com |
DB_PASSWORD |
A strong password (must match across all DB_* vars) |
LODGELY_HTTP_PORT |
Host port for HTTP (default 8080); change if that port is taken |
SESSION_SECURE_COOKIE |
true if serving over HTTPS, false for plain HTTP |
SESSION_DRIVER |
file is simplest; database works but requires the DB to be up first |
Set
DB_PASSWORDbefore the very firstdocker compose up. PostgreSQL initialises its data volume on first boot using the credentials present at that moment. If you changeDB_PASSWORDin.envafter the volume already exists, the old password stays baked in and the app will getpassword authentication failed. Fix:docker compose down -v && docker compose up -d --build(the-vflag removes the stale volume — only safe when you have no data to keep).
# 2. Build and start
docker compose up -d --build
# Brings up postgres, the php-fpm app, a queue worker, the scheduler
# (which runs the recurring imports — Google Sheets, IMAP, ad metrics,
# report emails, GDPR purge) and caddy.
# 3. Fix storage permissions (required on first start)
docker compose exec app chown -R www-data:www-data storage bootstrap/cache
# 4. First-time bootstrap
docker compose exec app composer install
docker compose exec app php artisan key:generate
docker compose exec app php artisan migrate --seed
docker compose exec app npm ci
docker compose exec app npm run buildThen open your APP_URL and sign in with one of the seeded accounts:
| Role | Password | Sees | |
|---|---|---|---|
| operator | operator@example.com |
password |
everything |
| client | client.northwind@example.com |
password |
leads with client_name = Northwind Studio |
| client | client.acme@example.com |
password |
leads with client_name = Acme Wellness |
🔒 Change these before going live. The seeded accounts exist so you can take lodgely for a spin — they should be removed or rotated on any real deployment.
To create a fresh user:
docker compose exec app php artisan lodgely:user:create \
--name="Jane Doe" --email=jane@example.com --role=operator
# or, scoped client:
docker compose exec app php artisan lodgely:user:create \
--name="Brand Owner" --email=owner@example.com --role=client \
--client="Northwind Studio"If lodgely sits behind Cloudflare, nginx, or any other reverse proxy:
- Set
APP_URLto the public HTTPS URL (e.g.https://lodgely.example.com), not the internal address. - Set
SESSION_SECURE_COOKIE=true(the browser is on HTTPS even if the internal hop is HTTP). - Set
SESSION_DRIVER=fileor ensureSESSION_DRIVER=databaseis working before testing login. - Forwarded headers (
X-Forwarded-Protoand friends) are trusted out of the box, so the app works behind a proxy with no extra config. - Once it works, set
TRUSTED_PROXIESto your proxy's address or CIDR —172.16.0.0/12for the bundled Docker stack. The default trusts every proxy, which means the "client IP" is whatever the caller puts inX-Forwarded-For. Narrowing it makes your access logs and IP-based limits meaningful. (Login and password-reset throttling does not depend on this: it is keyed on the submitted email address as well as the IP, precisely so a forged header cannot buy extra attempts.)
The defaults are tuned so a fresh docker compose up works on a laptop over
plain HTTP. An install holding real leads and live integration credentials
wants a few of them changed:
| Setting | Why |
|---|---|
APP_DEBUG=false |
Debug error pages render request and configuration detail. This is the default; make sure nothing in your shell or compose overrides it. |
APP_ENV=production |
Enables production behaviour, including forcing https:// in generated URLs. Set this after TLS works, or links will point at a scheme you do not serve. |
SESSION_SECURE_COOKIE=true |
Without it the session cookie is sent over plain HTTP too. Requires HTTPS. |
SESSION_ENCRYPT=true |
Already the default in .env.example; keep it. |
TRUSTED_PROXIES=<your proxy CIDR> |
See above. |
LODGELY_BACKUP_PASSPHRASE |
A backup archive contains every lead's name, email, phone and message body in cleartext. Setting a passphrase encrypts the database dump inside new archives. Existing unencrypted archives keep restoring normally. Store the passphrase somewhere other than the server it protects — an encrypted archive cannot be recovered without it. |
LODGELY_BACKUP_KEEP |
Archives otherwise accumulate forever, and each one is a full copy of your data that outlives retention_until. Set it to the number of archives to keep. |
LODGELY_DEFAULT_RETENTION_DAYS |
Empty means leads are kept until deleted by hand. Set a window and run the scheduler so lodgely:leads:purge can act on it. |
The app logs a warning at boot when it is running in production with debug
mode on or without a secure session cookie, so a misconfiguration shows up in
docker compose logs app rather than staying silent.
composer install
cp .env.example .env
php artisan key:generate
# Point .env at a local Postgres 16 instance
# DB_HOST=127.0.0.1, DB_DATABASE=lodgely, ...
php artisan migrate --seed
npm ci
npm run build
php artisan serveRun the queue worker in a second terminal:
php artisan queue:workAnd the scheduler in a third — without it none of the recurring jobs (Google Sheets fetch, Meta Lead Ads fetch, OpenFlow fetch, IMAP pull, ad-metrics import, report emails, GDPR purge) ever run:
php artisan schedule:work
# or add to crontab: * * * * * cd /path/to/lodgely && php artisan schedule:runUpload any UTF-8 CSV with a header row from Imports → CSV import. lodgely recognizes the following column aliases (case-insensitive):
| Logical field | Accepted column names |
|---|---|
full_name |
name, full_name, contact, contact name |
email |
email, email address, e-mail, mail |
phone |
phone, phone number, tel, telephone, mobile |
message |
message, note, comment, enquiry, inquiry |
client_name |
client, client_name, brand, account |
campaign_name |
campaign, campaign name, source campaign |
A working sample lives at database/samples/leads-sample.csv.
A modular monolith: server-rendered Blade + Livewire, no SPA. Domain
code lives under app/Domain/ (Leads/, Reporting/, Ai/, Demo/),
adapters for both lead sources and ad-metrics sources live under
app/Importers/, and UI lives in app/Livewire/.
Adding a new lead source means:
- Drop a class under
app/Importers/<Name>/implementingLeadSource. - Register it in
AppServiceProvider::IMPORTERS. - (Optionally) add a Livewire page to expose it in the UI.
No changes to migrations, models or the inbox are needed.
The full directory tree, the AI summary generation/review flow, and the Meta Lead Ads field mapping are documented in docs/ARCHITECTURE.md.
lodgely is built with privacy-by-design defaults, but you, the operator,
are the data controller for everything you put in it. The product gives
you the tools; the policies are yours: data minimization (only schema
fields are stored, raw payloads kept only for audit), per-lead
retention_until with an opt-in purge command, soft deletes, an audit
trail (lead_events), strict client-scoped access, and no telemetry or
external calls out of the box.
Full detail, including what lodgely deliberately does not do yet (consent capture, DSAR export, automatic erasure), is in docs/PRIVACY.md.
The handful of variables you're most likely to touch on a first install:
| Variable | Purpose | Default |
|---|---|---|
APP_URL |
Public URL of the install | http://localhost:8080 |
LODGELY_DEFAULT_RETENTION_DAYS |
Default lead retention, empty = retain | 365 |
LODGELY_EMAIL_IMPORT_DRIVER |
mock or imap |
mock |
LODGELY_AI_ENABLED |
Master kill-switch for the AI module | false |
TRUSTED_PROXIES |
Proxy address/CIDR to trust for X-Forwarded-* |
* (all) |
LODGELY_BACKUP_PASSPHRASE |
Encrypts the dump inside new backup archives | empty (off) |
LODGELY_BACKUP_KEEP |
Backup archives to retain on disk | empty (keep all) |
MAIL_MAILER |
Outbound mail transport (log, smtp) — prefer Settings → Email instead |
log |
DB_* |
Postgres credentials | see .env.example |
Every other variable — IMAP, Meta/Google Ads, Google Sheets OAuth, AI provider tuning, SMTP — has an in-app Settings page and an env-var fallback. The full reference (40+ variables) is in docs/CONFIGURATION.md.
- Stronger compliance tooling — lawful-basis tagging, DSAR export, one-click subject erasure.
- Multi-tenancy —
tenant_idexists everywhere; wire the full tenant-resolution stack so a single install can host many isolated workspaces.
The history of everything already shipped (reporting, AI, Meta Lead Ads, Google Sheets, i18n, dark mode, …) is in docs/ROADMAP.md; line-by-line changes are in CHANGELOG.md.
lodgely is a marketing tool. We ask, as a non-binding ethical request, that you do not use lodgely to run lead intake for clients in:
- Weapons and armaments
- Fossil-fuel energy (extraction, refining, distribution, generation)
- Internal-combustion / fossil-fuel passenger vehicles — electric vehicles, bicycles and public transit are explicitly fine.
This is a request from the maintainers, not a legal restriction (lodgely
remains GPL-3.0). See the preamble in LICENSE for the full statement.
GPL-3.0 — see LICENSE.
