This guide explains how to deploy a Laravel website on a shared cPanel hosting account where the Laravel application is stored inside cPanel's repositories folder, while the live domain is served through public_html.
This setup was built for a shared hosting environment where:
- The domain document root could not reliably be changed to Laravel's
/publicdirectory. - The entire
public_htmlfolder could not be symlinked to the repo's/publicfolder. - Symlinked asset folders were not reliable on LiteSpeed.
- Composer was not available globally, so
composer.pharwas installed inside the cPanel home directory. - Vite/Tailwind build assets are built locally and committed to Git.
- Deployment is handled by a reusable
deploy.shscript. - Optional web-based deployment is available through a protected Laravel
/deployroute.
The final structure should look like this:
/home/CPANEL_USER/
├── public_html/
│ ├── index.php
│ ├── .htaccess
│ ├── build/ <- copied from repo public/build during deployment
│ └── images/ <- copied from repo public/images during deployment
│
├── repositories/
│ └── PROJECT_NAME/
│ ├── app/
│ ├── bootstrap/
│ ├── config/
│ ├── database/
│ ├── public/
│ │ ├── build/
│ │ └── images/
│ ├── resources/
│ ├── routes/
│ ├── storage/
│ ├── vendor/ <- installed on the server by Composer
│ ├── .env <- server-only file, never committed
│ ├── artisan
│ ├── composer.json
│ ├── composer.lock
│ ├── package.json
│ ├── vite.config.js
│ ├── deploy.sh
│ └── .cpanel.yml <- optional, for cPanel Deploy HEAD Commit
│
└── composer.phar
Example for Danks & Strydom:
/home/danks/
├── public_html/
├── composer.phar
└── repositories/
└── DanksAndStrydom/
Before setting up Laravel on cPanel, ask the hosting provider to enable Terminal access for the cPanel account.
Example request:
Please enable Terminal or SSH access for this cPanel account.
We need terminal access to run Laravel commands, Composer, Git commands, and deployment scripts for a Laravel application hosted from the cPanel repositories folder.
Without terminal access, Laravel deployment becomes much harder because you cannot run Composer, Artisan, Git, or deployment commands.
Laravel expects the web server to point directly to the Laravel /public directory.
On many cPanel shared hosting accounts, the domain points to:
/home/CPANEL_USER/public_html
and the user may not be able to change the document root.
To work around this, we keep the Laravel app inside:
/home/CPANEL_USER/repositories/PROJECT_NAME
and use a custom public_html/index.php to load the Laravel app from the repo.
The public asset folders such as build and images are copied from the repo into public_html during deployment.
This is more reliable than symlinking on shared hosting because some LiteSpeed/cPanel environments do not serve symlinked public folders correctly.
In cPanel, go to:
Git Version Control
Clone the project into:
/home/CPANEL_USER/repositories/PROJECT_NAME
Example:
/home/danks/repositories/DanksAndStrydom
This creates a server-side clone of the GitHub repo.
Do not symlink the whole public_html folder to Laravel's /public folder.
On some shared hosts, Apache/LiteSpeed/cPanel does not serve public_html correctly when the entire folder is a symlink. This can result in a hosting-provider 404 page instead of a Laravel response.
Use a real public_html folder instead.
Copy the contents of:
/home/CPANEL_USER/repositories/PROJECT_NAME/public
into:
/home/CPANEL_USER/public_html
At minimum, public_html should contain:
index.php
.htaccess
favicon.ico
robots.txt
Do not copy the entire Laravel application into public_html.
Only the public entry files belong there.
The deploy script will later keep these asset folders updated:
public_html/build
public_html/images
Edit:
/home/CPANEL_USER/public_html/index.php
Change the paths so that it loads Laravel from the repo.
Use this structure:
<?php
use Illuminate\Foundation\Application;
use Illuminate\Http\Request;
define('LARAVEL_START', microtime(true));
// Determine if the application is in maintenance mode...
if (file_exists($maintenance = __DIR__.'/../repositories/PROJECT_NAME/storage/framework/maintenance.php')) {
require $maintenance;
}
// Register the Composer autoloader...
require __DIR__.'/../repositories/PROJECT_NAME/vendor/autoload.php';
// Bootstrap Laravel and handle the request...
/** @var Application $app */
$app = require_once __DIR__.'/../repositories/PROJECT_NAME/bootstrap/app.php';
$app->handleRequest(Request::capture());Example for Danks & Strydom:
<?php
use Illuminate\Foundation\Application;
use Illuminate\Http\Request;
define('LARAVEL_START', microtime(true));
if (file_exists($maintenance = __DIR__.'/../repositories/DanksAndStrydom/storage/framework/maintenance.php')) {
require $maintenance;
}
require __DIR__.'/../repositories/DanksAndStrydom/vendor/autoload.php';
/** @var Application $app */
$app = require_once __DIR__.'/../repositories/DanksAndStrydom/bootstrap/app.php';
$app->handleRequest(Request::capture());The repo's own file can remain unchanged:
/home/CPANEL_USER/repositories/PROJECT_NAME/public/index.php
That file should stay as Laravel's default public/index.php.
Edit:
/home/CPANEL_USER/public_html/.htaccess
Use Laravel's rewrite rules:
<IfModule mod_rewrite.c>
Options -MultiViews -Indexes
DirectoryIndex index.php
RewriteEngine On
RewriteCond %{HTTP:Authorization} .
RewriteRule .* - [E=HTTP_AUTHORIZATION:%{HTTP:Authorization}]
RewriteCond %{REQUEST_FILENAME} !-d
RewriteCond %{REQUEST_FILENAME} !-f
RewriteRule ^ index.php [L]
</IfModule>If you test symlinks and need ownership-matched symlink support, you can use:
Options +SymLinksIfOwnerMatch -MultiViews -IndexesHowever, if symlinked assets still return a PHP/Laravel 404, do not rely on symlinks. Use the copied asset folder approach in deploy.sh.
Make sure there are no Node.js Passenger rules in this file.
Remove anything like:
PassengerAppRoot
PassengerBaseURI
PassengerNodejs
PassengerStartupFile
PassengerAppType nodeNode.js must not serve the Laravel website.
Laravel must be served by PHP through:
public_html/index.php
Create:
/home/CPANEL_USER/repositories/PROJECT_NAME/.env
Example:
APP_NAME="Project Name"
APP_ENV=production
APP_KEY=base64:PASTE_APP_KEY_HERE
APP_DEBUG=false
APP_URL=https://example.com
LOG_CHANNEL=single
LOG_LEVEL=error
DB_CONNECTION=mysql
DB_HOST=localhost
DB_PORT=3306
DB_DATABASE=cpanelprefix_database
DB_USERNAME=cpanelprefix_user
DB_PASSWORD=database_password
CACHE_STORE=file
SESSION_DRIVER=file
QUEUE_CONNECTION=sync
MAIL_MAILER=smtp
MAIL_HOST=mail.example.com
MAIL_PORT=465
MAIL_USERNAME=info@example.com
MAIL_PASSWORD=email_password
MAIL_ENCRYPTION=ssl
MAIL_FROM_ADDRESS=info@example.com
MAIL_FROM_NAME="Project Name"
DEPLOY_TOKEN=PASTE_A_LONG_RANDOM_TOKEN_HEREGenerate the app key locally or on the server:
php artisan key:generate --showThen paste the generated value into:
APP_KEY=Generate a deploy token locally or on a machine with OpenSSL:
openssl rand -hex 48Paste that value into:
DEPLOY_TOKEN=Do not commit .env to Git.
In cPanel, go to:
Databases Wizard
Create:
- A database
- A database user
- Assign the user to the database
- Give the user
ALL PRIVILEGES
cPanel usually prefixes database names and usernames.
For example, if the cPanel username is:
danks
and you create a database called:
website
the real database name may be:
danks_website
Use the full cPanel-prefixed names in .env.
Example:
DB_DATABASE=danks_website
DB_USERNAME=danks_laravel
DB_PASSWORD=your_passwordIf Composer is not available globally, use composer.phar.
This was the working approach for this setup.
Go to the home directory:
cd /home/CPANEL_USERDownload Composer:
php -r "copy('https://getcomposer.org/installer', 'composer-setup.php');"Install Composer as composer.phar in the cPanel home directory:
php composer-setup.php --install-dir=/home/CPANEL_USER --filename=composer.pharRemove the installer:
php -r "unlink('composer-setup.php');"Check Composer works:
php /home/CPANEL_USER/composer.phar --versionExample for Danks & Strydom:
cd /home/danks
php -r "copy('https://getcomposer.org/installer', 'composer-setup.php');"
php composer-setup.php --install-dir=/home/danks --filename=composer.phar
php -r "unlink('composer-setup.php');"
php /home/danks/composer.phar --versionGo to the Laravel repo:
cd /home/CPANEL_USER/repositories/PROJECT_NAMEInstall production Composer dependencies:
php /home/CPANEL_USER/composer.phar install --no-dev --prefer-dist --optimize-autoloaderExample:
cd /home/danks/repositories/DanksAndStrydom
php /home/danks/composer.phar install --no-dev --prefer-dist --optimize-autoloaderThis creates the vendor folder on the server.
Because Composer now works on the server, vendor should not be committed to Git.
Use a mostly normal Laravel-style .gitignore.
Make sure these are ignored:
/vendor
/node_modules
.env
.env.backup
.env.production
/public/hot
/storage/*.key
/storage/logs/*
/storage/framework/cache/*
/storage/framework/sessions/*
/storage/framework/views/*
/bootstrap/cache/*.php
/.phpunit.cache
/.idea
/.vscode
.DS_StoreFor this shared-hosting setup, build Vite/Tailwind assets locally and commit public/build.
Do not ignore:
/public/buildIf your default .gitignore contains /public/build, remove that line.
Then run locally:
npm install
npm run build
git add public/build
git commit -m "Build production assets"
git push origin mainThe server does not need node_modules to serve the site. It only needs the compiled files in:
public/build
This setup originally tested symlinking:
/home/CPANEL_USER/public_html/build
→ /home/CPANEL_USER/repositories/PROJECT_NAME/public/build
and:
/home/CPANEL_USER/public_html/images
→ /home/CPANEL_USER/repositories/PROJECT_NAME/public/images
However, on some LiteSpeed/shared hosting setups, the symlink can exist in the shell but still return a Laravel/PHP 404 in the browser.
Example symptom:
ls -la /home/CPANEL_USER/public_html/build/manifest.json
# file exists
curl -I https://example.com/build/manifest.json
# HTTP/2 404
# x-powered-by: PHP/...That means LiteSpeed is not treating the symlink target as a normal static file and Laravel is catching the request.
For this reason, the recommended approach is to copy public asset folders during deployment instead of symlinking them.
The deployment script handles this automatically:
rm -rf /home/CPANEL_USER/public_html/build
cp -R /home/CPANEL_USER/repositories/PROJECT_NAME/public/build /home/CPANEL_USER/public_html/build
rm -rf /home/CPANEL_USER/public_html/images
cp -R /home/CPANEL_USER/repositories/PROJECT_NAME/public/images /home/CPANEL_USER/public_html/imagesLaravel needs write access to storage and bootstrap/cache.
Run:
chmod -R 775 /home/CPANEL_USER/repositories/PROJECT_NAME/storage
chmod -R 775 /home/CPANEL_USER/repositories/PROJECT_NAME/bootstrap/cacheExample:
chmod -R 775 /home/danks/repositories/DanksAndStrydom/storage
chmod -R 775 /home/danks/repositories/DanksAndStrydom/bootstrap/cachePublic files should generally be readable:
chmod 644 /home/CPANEL_USER/public_html/index.php
chmod 644 /home/CPANEL_USER/public_html/.htaccessCreate:
/home/CPANEL_USER/repositories/PROJECT_NAME/deploy.sh
Example:
nano /home/CPANEL_USER/repositories/PROJECT_NAME/deploy.shUse this script:
#!/bin/bash
set -euo pipefail
APP_DIR="/home/CPANEL_USER/repositories/PROJECT_NAME"
PUBLIC_DIR="/home/CPANEL_USER/public_html"
PHP_BIN="/usr/local/bin/php"
COMPOSER="/home/CPANEL_USER/composer.phar"
BRANCH="main"
LOG_FILE="$APP_DIR/storage/logs/deploy.log"
LOCK_FILE="/tmp/project-deploy.lock"
# Required when running Composer from a web-triggered process.
export HOME="/home/CPANEL_USER"
export COMPOSER_HOME="/home/CPANEL_USER/.composer"
export COMPOSER_CACHE_DIR="/home/CPANEL_USER/.composer/cache"
mkdir -p "$APP_DIR/storage/logs"
mkdir -p "$COMPOSER_HOME"
mkdir -p "$COMPOSER_CACHE_DIR"
bring_app_up() {
cd "$APP_DIR" || exit 1
echo "Bringing app back online..."
$PHP_BIN artisan up || true
}
(
flock -n 9 || {
echo "Another deployment is already running."
exit 1
}
# If anything fails after maintenance mode starts, this ensures the site is not left down.
trap bring_app_up EXIT
echo ""
echo "=================================================="
echo "Deployment started: $(date)"
echo "=================================================="
cd "$APP_DIR"
echo "Putting app into maintenance mode..."
$PHP_BIN artisan down || true
echo "Fetching latest code..."
git fetch origin "$BRANCH"
echo "Resetting working tree to origin/$BRANCH..."
git reset --hard "origin/$BRANCH"
echo "Installing Composer dependencies..."
$PHP_BIN "$COMPOSER" install --no-dev --prefer-dist --optimize-autoloader --no-interaction
echo "Copying public build assets..."
rm -rf "$PUBLIC_DIR/build"
cp -R "$APP_DIR/public/build" "$PUBLIC_DIR/build"
if [ -d "$APP_DIR/public/images" ]; then
echo "Copying public images..."
rm -rf "$PUBLIC_DIR/images"
cp -R "$APP_DIR/public/images" "$PUBLIC_DIR/images"
fi
echo "Fixing permissions..."
chmod -R 775 "$APP_DIR/storage" || true
chmod -R 775 "$APP_DIR/bootstrap/cache" || true
chmod -R 755 "$PUBLIC_DIR/build" || true
if [ -d "$PUBLIC_DIR/images" ]; then
chmod -R 755 "$PUBLIC_DIR/images" || true
fi
echo "Clearing Laravel caches..."
$PHP_BIN artisan optimize:clear
echo "Running database migrations..."
$PHP_BIN artisan migrate --force
echo "Rebuilding Laravel caches..."
$PHP_BIN artisan optimize
echo "Deployment completed: $(date)"
echo "=================================================="
) 9>"$LOCK_FILE" >> "$LOG_FILE" 2>&1For Danks & Strydom, the file should use:
APP_DIR="/home/danks/repositories/DanksAndStrydom"
PUBLIC_DIR="/home/danks/public_html"
PHP_BIN="/usr/local/bin/php"
COMPOSER="/home/danks/composer.phar"
LOCK_FILE="/tmp/danksandstrydom-deploy.lock"
export HOME="/home/danks"
export COMPOSER_HOME="/home/danks/.composer"
export COMPOSER_CACHE_DIR="/home/danks/.composer/cache"Make it executable:
chmod +x /home/CPANEL_USER/repositories/PROJECT_NAME/deploy.shExample:
chmod +x /home/danks/repositories/DanksAndStrydom/deploy.shRun it manually:
/home/CPANEL_USER/repositories/PROJECT_NAME/deploy.shExample:
/home/danks/repositories/DanksAndStrydom/deploy.shView the deployment log:
tail -n 120 /home/CPANEL_USER/repositories/PROJECT_NAME/storage/logs/deploy.logExample:
tail -n 120 /home/danks/repositories/DanksAndStrydom/storage/logs/deploy.logIf using cPanel's Deploy HEAD Commit button, create this file in the repo root:
.cpanel.yml
Use:
---
deployment:
tasks:
- /bin/bash /home/CPANEL_USER/repositories/PROJECT_NAME/deploy.shExample:
---
deployment:
tasks:
- /bin/bash /home/danks/repositories/DanksAndStrydom/deploy.shThis allows cPanel's deploy button to call the same deploy.sh file.
This is optional.
The safest deployment method is still terminal or cPanel deployment, because a Laravel route only works if Laravel can boot.
However, for convenience, you can add a protected POST-only route that calls deploy.sh.
DEPLOY_TOKEN=PASTE_A_LONG_RANDOM_TOKEN_HERE'deploy' => [
'token' => env('DEPLOY_TOKEN'),
],use Illuminate\Http\Request;
use Illuminate\Support\Facades\Process;
use Illuminate\Support\Facades\Route;
Route::post('/deploy', function (Request $request) {
$configuredToken = (string) config('services.deploy.token');
$providedToken = (string) $request->bearerToken();
abort_if($configuredToken === '', 404);
abort_unless(
hash_equals($configuredToken, $providedToken),
404
);
$result = Process::timeout(600)->run(
'/bin/bash /home/CPANEL_USER/repositories/PROJECT_NAME/deploy.sh'
);
if ($result->failed()) {
return response()->json([
'status' => 'failed',
'message' => 'Deployment failed. Check storage/logs/deploy.log on the server.',
'error' => $result->errorOutput(),
], 500);
}
return response()->json([
'status' => 'success',
'message' => 'Deployment completed.',
'output' => $result->output(),
]);
});Example for Danks & Strydom:
$result = Process::timeout(600)->run(
'/bin/bash /home/danks/repositories/DanksAndStrydom/deploy.sh'
);Call it with:
curl -X POST https://example.com/deploy \
-H "Authorization: Bearer YOUR_DEPLOY_TOKEN"Example:
curl -X POST https://danksandstrydom.co.za/deploy \
-H "Authorization: Bearer YOUR_DEPLOY_TOKEN"Do not use:
/deploy?token=...
because tokens in URLs can end up in browser history, access logs, analytics, and referrer headers.
Make changes locally.
If frontend assets changed, build them locally:
npm run buildCommit and push:
git add .
git commit -m "Update site"
git push origin mainRun:
/home/CPANEL_USER/repositories/PROJECT_NAME/deploy.shExample:
/home/danks/repositories/DanksAndStrydom/deploy.shIn cPanel:
Git Version Control
→ Select the repo
→ Update from Remote
→ Deploy HEAD Commit
The .cpanel.yml file should call deploy.sh.
Call the protected route:
curl -X POST https://example.com/deploy \
-H "Authorization: Bearer YOUR_DEPLOY_TOKEN"Run:
cd /home/CPANEL_USER/repositories/PROJECT_NAME
php artisan upExample:
cd /home/danks/repositories/DanksAndStrydom
php artisan upThe improved deploy.sh includes a trap that runs php artisan up when the script exits, even if a deployment step fails.
This can happen when deploy.sh is called through a web route because the web process does not have the same shell environment as the terminal.
Fix by including these lines in deploy.sh:
export HOME="/home/CPANEL_USER"
export COMPOSER_HOME="/home/CPANEL_USER/.composer"
export COMPOSER_CACHE_DIR="/home/CPANEL_USER/.composer/cache"
mkdir -p "$COMPOSER_HOME"
mkdir -p "$COMPOSER_CACHE_DIR"Example:
export HOME="/home/danks"
export COMPOSER_HOME="/home/danks/.composer"
export COMPOSER_CACHE_DIR="/home/danks/.composer/cache"If the site loads but has no styling, check:
ls -la /home/CPANEL_USER/public_html/build/manifest.json
curl -I https://example.com/build/manifest.jsonYou want:
HTTP/2 200
If public_html/build is a symlink and the curl returns a PHP/Laravel 404, replace the symlink with a copied folder:
rm -rf /home/CPANEL_USER/public_html/build
cp -R /home/CPANEL_USER/repositories/PROJECT_NAME/public/build /home/CPANEL_USER/public_html/buildThe deploy.sh file should now handle this automatically.
If you see a hosting-provider 404 page, Apache/LiteSpeed is probably not reaching Laravel.
Check:
ls -la /home/CPANEL_USER/public_htmlMake sure public_html is a real folder, not a full symlink to the repo.
If you see a Laravel 404, Apache/LiteSpeed is reaching Laravel, but the route does not exist.
Check:
routes/web.php
Make sure / is defined.
If the website shows a Node.js page saying "It works!" and displays a Node version, cPanel is serving a Node.js app instead of Laravel.
Fix:
- Go to cPanel.
- Open the Node.js app manager.
- Stop the Node.js app.
- Make sure the Node.js app is not assigned to the Laravel domain.
- Remove any Passenger rules from
public_html/.htaccess.
Laravel should be served by PHP, not Node.js.
If composer is not installed globally, use:
php /home/CPANEL_USER/composer.pharinstead of:
composerIf Laravel seems to ignore .env, delete cached config:
rm -f /home/CPANEL_USER/repositories/PROJECT_NAME/bootstrap/cache/config.phpThen run:
php artisan optimize:clear
php artisan config:cacheIf Laravel cannot write logs, cache, sessions, or compiled views, run:
chmod -R 775 /home/CPANEL_USER/repositories/PROJECT_NAME/storage
chmod -R 775 /home/CPANEL_USER/repositories/PROJECT_NAME/bootstrap/cacheFor this type of cPanel Laravel deployment:
Use Git for source code
Keep Laravel app in /repositories/PROJECT_NAME
Keep public_html as the public web entry folder
Customize public_html/index.php to load Laravel from the repo
Use composer.phar in the cPanel home directory
Ignore /vendor in Git
Ignore /node_modules in Git
Build Vite assets locally
Commit public/build
Keep public_html/build as a copied folder, not a symlink, if LiteSpeed blocks symlinks
Keep public_html/images as a copied folder, not a symlink, if LiteSpeed blocks symlinks
Keep .env only on the server
Use deploy.sh for repeatable deployment
Use .cpanel.yml if using cPanel Deploy HEAD Commit
Use a protected POST-only /deploy route only as a convenience
/home/danks/repositories/DanksAndStrydom/deploy.shOr manually:
cd /home/danks/repositories/DanksAndStrydom
git fetch origin main
git reset --hard origin/main
php /home/danks/composer.phar install --no-dev --prefer-dist --optimize-autoloader --no-interaction
rm -rf /home/danks/public_html/build
cp -R /home/danks/repositories/DanksAndStrydom/public/build /home/danks/public_html/build
rm -rf /home/danks/public_html/images
cp -R /home/danks/repositories/DanksAndStrydom/public/images /home/danks/public_html/images
php artisan optimize:clear
php artisan migrate --force
php artisan optimizeFor contact forms and website enquiries, the Laravel application should send mail using an email account from the same domain as the website.
For example, if the site is hosted on:
https://danksandstrydom.co.za
use a sending mailbox such as:
no-reply@danksandstrydom.co.za
Do not send website mail through an unrelated domain such as mail.valourite.co.za unless that domain is intentionally configured for this project. Using the same domain as the website helps avoid SPF, DKIM, DMARC, and authentication issues.
In cPanel, go to:
Email Accounts → Create
Create a mailbox such as:
no-reply@PROJECT_DOMAIN
Example:
no-reply@danksandstrydom.co.za
When creating the account, cPanel may ask for only the mailbox name and then let you select the domain separately.
Use:
no-reply
as the mailbox name, and select:
danksandstrydom.co.za
as the domain.
Do not accidentally create:
no-reply@danksandstrydom.co.za.co.za
If Laravel shows an SMTP username like this:
no-reply@danksandstrydom.co.za.co.za
then either the mailbox was created incorrectly or the .env value is wrong.
After creating the mailbox, go to:
Email Accounts → Connect Devices
Open the mail client settings for the mailbox.
The SMTP details will usually look like one of these options.
MAIL_MAILER=smtp
MAIL_HOST=mail.PROJECT_DOMAIN
MAIL_PORT=465
MAIL_USERNAME=no-reply@PROJECT_DOMAIN
MAIL_PASSWORD="mailbox_password_here"
MAIL_ENCRYPTION=ssl
MAIL_FROM_ADDRESS=no-reply@PROJECT_DOMAIN
MAIL_FROM_NAME="Project Name"Example:
MAIL_MAILER=smtp
MAIL_HOST=mail.danksandstrydom.co.za
MAIL_PORT=465
MAIL_USERNAME=no-reply@danksandstrydom.co.za
MAIL_PASSWORD="mailbox_password_here"
MAIL_ENCRYPTION=ssl
MAIL_FROM_ADDRESS=no-reply@danksandstrydom.co.za
MAIL_FROM_NAME="Danks & Strydom Physiotherapy"MAIL_MAILER=smtp
MAIL_HOST=mail.PROJECT_DOMAIN
MAIL_PORT=587
MAIL_USERNAME=no-reply@PROJECT_DOMAIN
MAIL_PASSWORD="mailbox_password_here"
MAIL_ENCRYPTION=tls
MAIL_FROM_ADDRESS=no-reply@PROJECT_DOMAIN
MAIL_FROM_NAME="Project Name"Example:
MAIL_MAILER=smtp
MAIL_HOST=mail.danksandstrydom.co.za
MAIL_PORT=587
MAIL_USERNAME=no-reply@danksandstrydom.co.za
MAIL_PASSWORD="mailbox_password_here"
MAIL_ENCRYPTION=tls
MAIL_FROM_ADDRESS=no-reply@danksandstrydom.co.za
MAIL_FROM_NAME="Danks & Strydom Physiotherapy"Use the exact SMTP host, port, and encryption shown by cPanel.
If the application uses a contact form, add the recipient email address or addresses to .env:
CONTACT_MAIL_TO=info@PROJECT_DOMAINExample:
CONTACT_MAIL_TO=info@danksandstrydom.co.zaFor multiple recipients, separate them with commas:
CONTACT_MAIL_TO=info@danksandstrydom.co.za,admin@danksandstrydom.co.zaDo not expose these recipient addresses in the frontend.
If the mailbox password contains special characters such as:
# $ % ! @ spaces
wrap it in quotes:
MAIL_PASSWORD="abc#123!password"This prevents Laravel from reading the password incorrectly.
After changing mail settings in .env, run:
cd /home/CPANEL_USER/repositories/PROJECT_NAME
php artisan optimize:clear
php artisan config:cacheExample:
cd /home/danks/repositories/DanksAndStrydom
php artisan optimize:clear
php artisan config:cacheIf the application uses the deploy.sh script, it will also rebuild Laravel's cached config during deployment, but it is still useful to run these commands immediately after changing .env.
Use Tinker:
php artisan tinkerThen check:
config('mail.mailers.smtp.host');
config('mail.mailers.smtp.port');
config('mail.mailers.smtp.encryption');
config('mail.mailers.smtp.username');
config('mail.from.address');The username should be the full mailbox address:
no-reply@danksandstrydom.co.za
It should not be:
no-reply@danksandstrydom.co.za.co.za
Exit Tinker:
exitIn Tinker, run:
use Illuminate\Support\Facades\Mail;
Mail::raw('Test email from the Laravel website.', function ($message) {
$message->to('your-email@example.com')
->subject('Laravel mail test');
});Replace:
your-email@example.com
with a real email address you can check.
If the message sends successfully, the SMTP configuration is working.
For contact forms, the email should be sent from the authenticated cPanel mailbox, not from the visitor's email address.
Correct:
From: Danks & Strydom Physiotherapy <no-reply@danksandstrydom.co.za>
Reply-To: Website Visitor <visitor@example.com>
To: info@danksandstrydom.co.za
Incorrect:
From: Website Visitor <visitor@example.com>
To: info@danksandstrydom.co.za
Using the visitor's email as the from address can cause SPF, DKIM, DMARC, and SMTP authentication failures.
Example Laravel Mailable envelope:
use Illuminate\Mail\Mailables\Address;
use Illuminate\Mail\Mailables\Envelope;
public function envelope(): Envelope
{
return new Envelope(
from: new Address(
config('mail.from.address'),
config('mail.from.name')
),
replyTo: [
new Address($this->email, $this->name),
],
subject: 'New website enquiry from ' . $this->name,
);
}In cPanel, go to:
Email Deliverability
Check the domain.
Make sure these records are valid:
SPF
DKIM
DMARC, if configured
If cPanel offers a Repair button, use it.
Even if SMTP authentication works, missing SPF/DKIM records can cause emails to land in spam or be rejected by some mail providers.
Example:
535 Incorrect authentication data
Check:
MAIL_USERNAME
MAIL_PASSWORD
MAIL_HOST
MAIL_PORT
MAIL_ENCRYPTION
Make sure MAIL_USERNAME is the full email address:
MAIL_USERNAME=no-reply@danksandstrydom.co.zaMake sure the password is the mailbox password created in cPanel.
If the password contains special characters, wrap it in quotes.
If the error shows:
no-reply@danksandstrydom.co.za.co.za
fix .env and/or recreate the cPanel email account correctly.
The correct username is:
no-reply@danksandstrydom.co.za
Then run:
php artisan optimize:clear
php artisan config:cacheCheck:
Spam/junk folder
cPanel Email Deliverability
SPF/DKIM records
Recipient address in CONTACT_MAIL_TO
Laravel logs
View Laravel logs:
tail -n 100 /home/CPANEL_USER/repositories/PROJECT_NAME/storage/logs/laravel.logExample:
tail -n 100 /home/danks/repositories/DanksAndStrydom/storage/logs/laravel.log