An open-source forum built with Laravel: categories, moderation, Google sign-in and two-factor authentication.
- About
- Features
- Built With
- Local Development
- Deployment
- Contributing
- Security Vulnerabilities
- License
Project Zintos is an open-source forum and community platform built on Laravel. Members organise discussions into categories and subcategories, write rich-text posts with images, GIFs, video and code blocks, comment on each other's posts, and build up a profile. Moderators review new content before it goes public, and administrators manage users, groups and site analytics from the same interface.
It is designed to be easy to self-host. Everything it needs, from the web server to Google sign-in and transactional email, is covered step by step in the deployment guide below.
- Categories and subcategories. Every post is filed under a category and one of its subcategories.
- Rich-text posts. A self-hosted TinyMCE editor with links, tables and syntax-labelled code blocks. All HTML is sanitized on the server with HTMLPurifier before it is stored.
- Media attachments. One JPG, PNG, GIF or MP4 file (up to 5 MB) per post.
- Comments, likes and view counts.
- Moderation. New posts, and edits made by regular members, wait for approval. Moderators and administrators can approve posts and comments and pin or unpin posts.
- User groups. Administrator, moderator and premium roles, with group administration for admins.
- Authentication. Email and password with email verification, Sign in with Google, and two-factor authentication through Laravel Fortify.
- Bot protection. Cloudflare Turnstile on the login, registration and password reset forms.
- Member profiles. Avatars, biographies, member search and a team page.
- Analytics dashboard. Google Analytics 4 statistics and charts for administrators.
- Cookie consent. Analytics scripts load only after a visitor accepts the banner.
- Themes. daisyUI themes that follow the visitor's light or dark system preference.
- Custom error pages and an installable web app manifest.
- Laravel, PHP 8.4 and PostgreSQL
- Vite, Tailwind CSS and daisyUI
- TinyMCE (self-hosted) and mews/purifier
- Laravel Fortify and Laravel Socialite
- spatie/laravel-analytics and whitecube/laravel-cookie-consent
- Resend for email and Cloudflare Turnstile for bot protection
Requirements: PHP 8.4 with the pgsql, zip, gd, mbstring, curl, xml, bcmath and intl extensions, Composer, Node.js (current LTS) with npm, and PostgreSQL.
git clone https://github.com/youruser/your-repo.git
cd your-repo
composer install
npm install
cp .env.example .env
php artisan key:generateCreate a PostgreSQL database and user (the commands are in step 5 of the deployment guide) and put the credentials in .env. Then run:
php artisan migrate --seed
php artisan storage:link
mkdir -p storage/app/public/assets/img/favicon storage/app/public/assets/img/itemsAdd your favicons and a background.gif to those two folders, as described in step 11. Then start the two development servers in separate terminals:
npm run dev
php artisan serveThe app is now running at http://localhost:8000.
A few settings are easier locally:
- Email. Set
MAIL_MAILER=logand messages are written tostorage/logs/laravel.loginstead of being sent. - Turnstile. Cloudflare publishes test keys that always pass, so you don't need to create a widget.
- Google sign-in. Add
http://localhost:8000/auth/google/callbackas an extra authorized redirect URI in Google Cloud, and use the same value forGOOGLE_CALLBACK_URL. Step 13.1 explains how to get the credentials. - Analytics. Leave the analytics keys empty if you don't need the dashboard.
This guide walks through a full production deployment on an Ubuntu 24.04 VPS: PHP 8.4, Composer, Nginx, PostgreSQL, Node and Vite, HTTPS through Let's Encrypt, and every third-party service the app needs (Google sign-in, Google Analytics, Resend email and Cloudflare Turnstile).
It is written for a Hostinger VPS, but nothing in it is Hostinger-specific except the DNS and firewall panel references. Any Ubuntu 24.04 VPS works.
Never commit secrets.
.envandstorage/app/analytics/service-account-credentials.jsonmust stay out of git. Both are listed in.gitignorealready, but double-check before pushing a fork.
| Placeholder | Example |
|---|---|
yourdomain.com |
myapp.com (the domain you bought) |
youruser/your-repo |
johndoe/my-laravel-app |
laravelapp |
folder name on disk, and the database name |
dbuser / dbpassword |
your database credentials |
YOUR_VPS_IP |
the public IP of your VPS |
| Service | Used for | Cost |
|---|---|---|
| Google Cloud | "Sign in with Google" and the Analytics dashboard | Free |
| Google Analytics (GA4) | Visitor statistics | Free |
| Resend | Sending email (verification, password reset) | Free tier available, check current limits |
| Cloudflare Turnstile | Bot protection on login, register, and reset forms | Free |
Step 13 explains how to get every key.
Start here, because DNS propagation can take a while.
- In your domain registrar or hosting panel (on Hostinger: hPanel → Domains → your domain → DNS), add these records:
| Type | Name | Points to | TTL |
|---|---|---|---|
| A | @ |
YOUR_VPS_IP |
300 (or default) |
| A | www |
YOUR_VPS_IP |
300 (or default) |
- Check propagation:
dig +short yourdomain.com
dig +short www.yourdomain.comBoth should print YOUR_VPS_IP.
sudo apt update && sudo apt upgrade -yUbuntu 24.04 doesn't ship PHP 8.4, so add the Ondřej Surý PPA first.
sudo apt install software-properties-common -y
sudo add-apt-repository ppa:ondrej/php -y
sudo apt update
sudo apt install php8.4-fpm php8.4-cli php8.4-common php8.4-pgsql php8.4-zip \
php8.4-gd php8.4-mbstring php8.4-curl php8.4-xml php8.4-bcmath php8.4-intl \
php8.4-opcache php8.4-redis -y
php -v # should report PHP 8.4.xcd ~
curl -sS https://getcomposer.org/installer | php
sudo mv composer.phar /usr/local/bin/composer
sudo chmod +x /usr/local/bin/composer
composer --versionsudo apt install git -ysudo apt install postgresql postgresql-contrib -y
sudo systemctl enable postgresql
sudo systemctl start postgresql
sudo -u postgres psqlInside the psql shell:
CREATE DATABASE laravelapp;
CREATE USER dbuser WITH PASSWORD 'dbpassword';
GRANT ALL PRIVILEGES ON DATABASE laravelapp TO dbuser;
ALTER DATABASE laravelapp OWNER TO dbuser;
\du
\qALTER DATABASE ... OWNER TO matters: without ownership, migrations can fail with permission errors even after GRANT ALL.
sudo apt install nginx -y
sudo systemctl enable nginxNeeded to build the frontend (Vite).
curl -fsSL https://deb.nodesource.com/setup_lts.x | sudo -E bash -
sudo apt install nodejs -y
node -v
npm -vSkip this step if the repo is public.
ssh-keygen -t ed25519 -C "deploy@yourdomain.com" -f ~/.ssh/id_ed25519 -N ""
cat ~/.ssh/id_ed25519.pubAdd the printed key in GitHub → your repo → Settings → Deploy keys (read-only is enough), then test:
ssh -T git@github.comGenerate the key as your regular user, not root.
sudo mkdir -p /var/www
cd /var/www
sudo mkdir -p /var/www/laravelapp
sudo chown $USER:$USER /var/www/laravelapp
git clone https://github.com/youruser/your-repo.git laravelapp
# private repo over SSH:
# git clone git@github.com:youruser/your-repo.git laravelapp
cd laravelappDon't use sudo on git clone: root can't see the deploy key you generated under your own user.
Nginx runs as www-data, so give it group access.
sudo chown -R $USER:www-data /var/www/laravelapp
sudo find /var/www/laravelapp -type f -exec chmod 664 {} \;
sudo find /var/www/laravelapp -type d -exec chmod 775 {} \;
sudo chmod -R ug+rwx /var/www/laravelapp/storage /var/www/laravelapp/bootstrap/cache
# Stop git from reporting these permission changes as file modifications
cd /var/www/laravelapp
git config core.fileMode falsecd /var/www/laravelapp
mkdir -p storage/app/temp
mkdir -p storage/app/analytics
mkdir -p storage/app/public/assets/img/favicon
mkdir -p storage/app/public/assets/img/items
sudo chown -R $USER:www-data storage/app/temp storage/app/analytics storage/app/public/assets
touch storage/app/analytics/service-account-credentials.json
sudo chmod -R ug+rwx storage/app/temp
sudo find storage/app/public/assets -type d -exec chmod 775 {} \;
sudo find storage/app/public/assets -type f -exec chmod 664 {} \;The empty service-account-credentials.json is a placeholder. Step 13.2 replaces it with the real file.
Edit the PHP-FPM config:
sudo nano /etc/php/8.4/fpm/php.iniupload_tmp_dir = /var/www/laravelapp/storage/app/temp
sys_temp_dir = /var/www/laravelapp/storage/app/temp
upload_max_filesize = 5M
post_max_size = 8M
expose_php = OffThe app accepts post images and videos up to 5 MB, so upload_max_filesize must be at least 5M and post_max_size must be larger than that. Nginx's client_max_body_size (step 18) must be at least as large as post_max_size.
Apply the same two temp-dir lines to the CLI config if you run queue workers or upload-related Artisan commands:
sudo nano /etc/php/8.4/cli/php.iniRestart and verify:
sudo systemctl restart php8.4-fpm
php -i | grep -i "upload_tmp_dir\|sys_temp_dir"The app expects two sets of images in storage/app/public/assets/img/. The php artisan storage:link command in step 12 exposes that folder at /storage/assets/img/. The images aren't shipped with the repository, so you add your own.
Favicons: storage/app/public/assets/img/favicon/
-
Go to favicon.io and generate your icons from an image, text, or an emoji.
-
Download the zip and extract it. You'll get
favicon.ico,favicon-16x16.png,favicon-32x32.png,apple-touch-icon.png,android-chrome-192x192.png,android-chrome-512x512.pngandsite.webmanifest. -
Open
site.webmanifest, fill in your app'snameandshort_name, and change the icon paths to the new folder. favicon.io writes them as/android-chrome-..., which would return 404 because the files live in a subfolder:"src": "/storage/assets/img/favicon/android-chrome-192x192.png" "src": "/storage/assets/img/favicon/android-chrome-512x512.png"
-
Upload everything from your own computer:
scp favicon_io/* youruser@YOUR_VPS_IP:/var/www/laravelapp/storage/app/public/assets/img/favicon/
Background image: storage/app/public/assets/img/items/background.gif
Add a GIF named exactly background.gif. It's used as the background of the category pages and the 404 page. Keep the file small (a few MB at most), because it loads with those pages.
scp background.gif youruser@YOUR_VPS_IP:/var/www/laravelapp/storage/app/public/assets/img/items/These files aren't tracked by git (storage/app/public is gitignored), so they stay on the server and survive git pull. Keep a copy on your own computer in case you rebuild the server.
cd /var/www/laravelapp
composer install --optimize-autoloader --no-dev
php artisan storage:linkThese produce every value in the .env block you'll fill in at step 14. Do them in any order. A few need your domain to be live, and say so.
-
Open the Google Cloud Console and create a project (or pick an existing one).
-
Go to Google Auth Platform and click Get started. Fill in the app name and a support email, choose External as the audience, and add your contact email.
-
In Google Auth Platform → Clients, click Create client.
-
Set Application type to Web application and give it a name.
-
Leave Authorized JavaScript origins empty.
-
Under Authorized redirect URIs, click Add URI and enter your callback URL:
https://yourdomain.com/auth/google/callbackThis must match the callback route defined in
routes/web.phpexactly, includinghttps, the domain, and the path. A mismatch causes aredirect_uri_mismatcherror at login. -
Click Create. Google shows the Client ID and Client secret. Copy both now, because the secret can't be shown again. If you lose it, create a new client.
-
Set the same callback URL in your
.env:GOOGLE_CLIENT_ID=123456789-abc.apps.googleusercontent.com GOOGLE_CLIENT_SECRET=GOCSPX-xxxxxxxxxxxx GOOGLE_CALLBACK_URL=https://yourdomain.com/auth/google/callback
Publish the app, or only you can log in. New projects start in Testing mode, where only the test users you list can sign in. When you're ready for the public, go to Google Auth Platform → Audience and click Publish app (set it to In production). Sign-in with basic profile and email scopes doesn't require Google's verification review.
These are two different IDs, and the app needs both.
- Open Google Analytics and create a GA4 property for your site, with a Web data stream pointing at
https://yourdomain.com. GOOGLE_ANALYTICS_IDis the Measurement ID. Find it in Admin → Data streams → your stream. It starts withG-, for exampleG-ABC123XYZ4. The tracking script loads only after a visitor accepts the cookie banner.ANALYTICS_PROPERTY_IDis the Property ID. Find it in Admin → Property details. It is a number only, with noG-prefix, for example123456789. The admin analytics dashboard uses it.
The dashboard also needs a service account so the app can read your stats:
-
In the same Google Cloud project, open APIs & Services → Library, search for Google Analytics Data API, and click Enable.
-
Go to IAM & Admin → Service Accounts → Create service account. Give it a name and skip the optional role steps.
-
Open the new service account, go to Keys → Add key → Create new key → JSON. A
.jsonfile downloads. -
Copy the service account's email address (it looks like
name@project.iam.gserviceaccount.com). In Google Analytics, go to Admin → Property access management, click +, add that email, and give it the Viewer role. -
Upload the JSON file to your server from your own computer:
scp service-account-credentials.json youruser@YOUR_VPS_IP:/var/www/laravelapp/storage/app/analytics/
Then lock down its permissions on the server:
cd /var/www/laravelapp sudo chown $USER:www-data storage/app/analytics/service-account-credentials.json sudo chmod 640 storage/app/analytics/service-account-credentials.json
Upload it with
scprather than pasting it into an editor. The private key contains line breaks that are easy to mangle by hand. It lives instorage/, outsidepublic/, so the web server never serves it.
GOOGLE_ANALYTICS_ID=G-ABC123XYZ4
ANALYTICS_PROPERTY_ID=123456789A VPS shouldn't send email directly. Its IP has no sending reputation, so mail lands in spam, and many hosts block port 25. Resend handles delivery.
- Create an account at resend.com.
- Go to Domains → Add Domain and enter
yourdomain.com. - Resend shows several DNS records (SPF, DKIM, and optionally DMARC). Add them exactly as shown in your DNS panel, the same place you added the A records in step 0.
- Click Verify in Resend. This can take a few minutes to a few hours.
- Go to API Keys → Create API Key. Choose Sending access, and copy the key. It starts with
re_and is only shown once.
RESEND_API_KEY=re_xxxxxxxxxxxx
MAIL_MAILER=resend
MAIL_FROM_ADDRESS=noreply@yourdomain.com
MAIL_FROM_NAME="${APP_NAME}"MAIL_FROM_ADDRESS must use the domain you verified in step 3. Until the domain is verified, Resend only lets you send test emails to your own account's address.
You don't need to move your DNS to Cloudflare to use Turnstile.
- Create a free account at cloudflare.com and open Turnstile from the sidebar.
- Click Add widget and give it a name.
- Under Hostname management, add
yourdomain.com. Addlocalhostas well if you want it to work during local development. - Choose the Managed widget mode, then click Create.
- Copy the Site Key (public) and the Secret Key (private).
TURNSTILE_SITE_KEY=0x4AAAAAAAxxxxxxxxxx
TURNSTILE_SECRET_KEY=0x4AAAAAAAxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxLocal development: Cloudflare publishes dummy keys that always pass, so you don't have to register localhost. Check Cloudflare's testing docs for the current values and never use them in production.
cd /var/www/laravelapp
cp .env.example .env
nano .envFill in everything, using the values from step 13. If any of these keys are missing from .env.example, add them:
APP_NAME="Your App"
APP_ENV=production
APP_KEY=
APP_DEBUG=false
APP_URL=https://yourdomain.com
DB_CONNECTION=pgsql
DB_HOST=127.0.0.1
DB_PORT=5432
DB_DATABASE=laravelapp
DB_USERNAME=dbuser
DB_PASSWORD=dbpassword
GOOGLE_CLIENT_ID=
GOOGLE_CLIENT_SECRET=
GOOGLE_CALLBACK_URL=https://yourdomain.com/auth/google/callback
ANALYTICS_PROPERTY_ID=
GOOGLE_ANALYTICS_ID=
RESEND_API_KEY=
MAIL_MAILER=resend
MAIL_FROM_ADDRESS=noreply@yourdomain.com
MAIL_FROM_NAME="${APP_NAME}"
TURNSTILE_SITE_KEY=
TURNSTILE_SECRET_KEY=APP_URL uses https:// even though the certificate isn't issued until step 19. Laravel uses it to build links.
Generate the application key, then restrict who can read the file. .env holds your database password and API keys, and PHP-FPM only needs group read access:
php artisan key:generate
sudo chown $USER:www-data .env
sudo chmod 640 .envphp artisan migrate --force --seed--force is required in production. --seed is required here because it creates the user groups the app depends on.
Making yourself an admin: register an account through the site first, then promote it. This assumes the seeder created a group with is_admin set:
php artisan tinker\App\Models\User::where('email', 'you@example.com')->update([
'group_id' => \App\Models\Group::where('is_admin', true)->value('id'),
]);npm install
npm run buildphp artisan config:cache
php artisan route:cache
php artisan view:cache
php artisan event:cacheRe-run these any time you change .env, or Laravel keeps using the old cached values.
Running Artisan as your own user can create log and cache files that PHP-FPM (www-data) can't write to, which causes 500 errors. Fix the ownership once more:
sudo chown -R $USER:www-data storage bootstrap/cache
sudo chmod -R ug+rwx storage bootstrap/cachesudo nano /etc/nginx/sites-available/laravelappserver {
listen 80;
listen [::]:80;
server_name yourdomain.com www.yourdomain.com;
root /var/www/laravelapp/public;
client_max_body_size 8M;
server_tokens off;
add_header X-Frame-Options "SAMEORIGIN";
add_header X-Content-Type-Options "nosniff";
index index.php;
charset utf-8;
location / {
try_files $uri $uri/ /index.php?$query_string;
}
location = /favicon.ico { access_log off; log_not_found off; }
location = /robots.txt { access_log off; log_not_found off; }
error_page 404 /index.php;
# Never execute PHP from the uploads folder (must come before the .php block)
location ~* ^/storage/.*\.php$ {
deny all;
}
location ~ \.php$ {
fastcgi_pass unix:/run/php/php8.4-fpm.sock;
fastcgi_param SCRIPT_FILENAME $realpath_root$fastcgi_script_name;
include fastcgi_params;
}
location ~ /\.(?!well-known).* {
deny all;
}
}Enable the site and remove the default:
sudo ln -s /etc/nginx/sites-available/laravelapp /etc/nginx/sites-enabled/
sudo rm -f /etc/nginx/sites-enabled/default
sudo nginx -t
sudo systemctl reload nginxhttp://yourdomain.com should now load the app once DNS has propagated. Confirm that before moving on.
If ufw is active:
sudo ufw status
sudo ufw allow 'Nginx Full'
sudo ufw allow OpenSSH
sudo ufw reloadNginx Full opens ports 80 and 443. If your hosting provider has its own firewall panel (Hostinger: hPanel → VPS → Firewall), make sure 80 and 443 are allowed there too.
sudo apt install certbot python3-certbot-nginx -y
sudo certbot --nginx -d yourdomain.com -d www.yourdomain.comEnter an email, accept the terms, and choose to redirect HTTP to HTTPS when asked. Then confirm renewal works:
sudo systemctl status certbot.timer
sudo certbot renew --dry-runsudo systemctl status php8.4-fpm
sudo systemctl status nginx
sudo systemctl status postgresql
curl -I https://yourdomain.comThe response headers should not contain Server: nginx/1.x.x or X-Powered-By: PHP/....
Then test each integration from the browser:
-
Uploads: upload a post image and confirm there are no errors in
storage/logs/laravel.log. -
Google sign-in: click "Sign in with Google" and confirm you land back on the site, logged in.
-
Turnstile: the widget appears on the login and register forms, and submitting works.
-
Email: send a test message from the server:
php artisan tinker
Mail::raw('Test email', fn ($m) => $m->to('you@example.com')->subject('Test'));
Check Resend's Emails log if it doesn't arrive.
-
Analytics: accept the cookie banner, then open the admin Analytics page. Real-time data can take a few minutes to appear.
-
Review the legal pages. The footer links to
/cookies,/privacyand/terms. Replace any project names and contact details in them with your own. -
Lock down SSH. Use key-based login, disable root login, and install
fail2ban. -
Enable automatic security updates with
unattended-upgrades. -
Back up the database. A daily cron job running
pg_dump, with the result copied off the server:mkdir -p ~/backups PGPASSWORD='dbpassword' pg_dump -U dbuser -h 127.0.0.1 laravelapp | gzip > ~/backups/laravelapp-$(date +%F).sql.gz
Also back up
.env,storage/app/public(user uploads), and the analytics credentials file. -
Keep dependencies updated. Run
composer auditandnpm auditregularly, and bumptinymcewhen security releases ship.
cd /var/www/laravelapp
git pull origin main
composer install --optimize-autoloader --no-dev
php artisan migrate --force
npm install
npm run build
php artisan config:cache
php artisan route:cache
php artisan view:cache
sudo chown -R $USER:www-data storage bootstrap/cache
sudo chmod -R ug+rwx storage bootstrap/cache
sudo systemctl restart php8.4-fpmYour uploaded favicons, background.gif and the .env file aren't touched by git pull, so they survive redeploys.
Consider wrapping this in a deploy.sh script, or moving to CI/CD (GitHub Actions with SSH deploy) as the project grows.
| Symptom | Likely cause |
|---|---|
| 502 Bad Gateway | PHP-FPM not running, or wrong socket path in the Nginx config |
| 500 error, blank page | Temporarily set APP_DEBUG=true and check storage/logs/laravel.log. Set it back to false afterward |
| "Permission denied" on storage or cache | Re-run the chown/chmod commands from step 10 |
| Migrations fail to connect | Check the .env DB credentials and that PostgreSQL is running (sudo systemctl status postgresql) |
| Migration permission errors | The DB user doesn't own the database. Run ALTER DATABASE laravelapp OWNER TO dbuser; |
| Composer install fails on memory | COMPOSER_MEMORY_LIMIT=-1 composer install --optimize-autoloader --no-dev |
| File uploads fail with "failed to open stream" | storage/app/temp is missing or not writable by www-data, or upload_tmp_dir doesn't point to it. Restart php8.4-fpm |
nginx -t says listen directive is not allowed here |
The config is missing its opening server { line, or sites-enabled/laravelapp isn't a symlink |
| Certbot fails the HTTP-01 challenge | DNS hasn't propagated, or port 80 is blocked in ufw or the provider firewall |
www.yourdomain.com doesn't resolve |
Add the missing www A record (step 0) |
Changed .env but nothing changes |
Run php artisan config:cache (or config:clear) |
Google login: redirect_uri_mismatch |
GOOGLE_CALLBACK_URL and the URI in Google Cloud don't match character for character |
| Google login: "Access blocked" / only you can sign in | The OAuth app is still in Testing mode. Publish it under Google Auth Platform → Audience |
| Analytics dashboard empty or erroring | The service account isn't a Viewer on the GA4 property, the Analytics Data API isn't enabled, or ANALYTICS_PROPERTY_ID is the G- Measurement ID instead of the numeric Property ID |
| Emails don't arrive | The Resend domain isn't verified yet, or MAIL_FROM_ADDRESS uses a different domain. Check the Resend dashboard logs |
| Turnstile widget missing or always failing | The site key's hostname list doesn't include your domain, or the site key and secret key are swapped |
| Uploads fail, or "must be 5 MB or smaller" appears for files under 5 MB | upload_max_filesize or post_max_size in php.ini, or client_max_body_size in Nginx, is too low. Use 5M / 8M / 8M, then restart php8.4-fpm and reload Nginx |
413 Request Entity Too Large |
client_max_body_size in Nginx is smaller than the upload |
| Favicon missing, or the manifest fails to load | storage/app/public/assets/img/favicon/ is empty, php artisan storage:link hasn't been run, or the icon paths in site.webmanifest still start with /android-chrome- instead of /storage/assets/img/favicon/ |
| Plain page background, or a broken image on category pages and the 404 page | storage/app/public/assets/img/items/background.gif is missing, or php artisan storage:link hasn't been run. The file name must match exactly |
laravel.log "permission denied" after running Artisan |
Artisan created files owned by your user. Re-run the chown and chmod on storage and bootstrap/cache from step 17 |
Contributions are welcome. Fork the repository, create a branch for your change, and open a pull request. For larger changes, please open an issue first so we can agree on the approach.
If you discover a security vulnerability, please don't open a public issue. Report it privately using the Report a vulnerability button in the repository's Security tab. Reports will be addressed promptly.
This project is open-sourced software licensed under the MIT license.