How to Deploy a Laravel App to cPanel Shared Hosting Safely
Deploy a Laravel app to cPanel shared hosting safely: code outside public_html, document root on public/, .env, permissions, MySQL, cron and common errors.

You can deploy a Laravel app to cPanel shared hosting safely if you follow one rule: the application code lives outside public_html, and only Laravel’s public/ folder is exposed to the web. After that, it is a matter of setting up .env, the database, file permissions and a cron job for the scheduler. This guide walks through each step, the choice between SSH and FTP deploys, and the errors we see most often.
Before you start: check the hosting
Not every shared host can run a current Laravel version well. As of September 2026, Laravel 13 is the current major release and the default laravel/laravel skeleton requires PHP 8.3 or newer. Check the following in cPanel before you upload anything:
- PHP version: select 8.3 or newer in MultiPHP Manager or Select PHP Version.
- PHP extensions: Laravel lists
ctype,curl,dom,fileinfo,filter,hash,mbstring,openssl,pcre,pdo,session,tokenizerandxml, and for MySQL you also needpdo_mysql. See the Laravel deployment docs for the current list. - SSH access: many hosts offer it but leave it off by default. It makes everything easier.
- Composer: often available over SSH; if not, you will build
vendor/locally or in CI. - Document root control: whether you can change the document root of the main domain or only of addon domains and subdomains.
The safe folder layout
The most common mistake is uploading the whole Laravel project into public_html. That can expose .env, storage/ and vendor/ to anyone who guesses the path. Use this layout instead:
/home/youruser/
├── laravel-app/ ← the whole project (app, config, vendor, .env, storage…)
│ └── public/ ← the only folder the web server should serve
└── public_html/ ← either the document root is changed, or this becomes a thin shim
Option A: point the document root at public/ (preferred)
In cPanel, go to Domains, edit the domain and set the document root to laravel-app/public. For addon domains and subdomains this is usually straightforward. Some hosts also allow it for the primary domain; others lock it to public_html.
Option B: symlink public_html to public/
If you have SSH and the host allows symlinks, replace public_html with a link:
cd ~
mv public_html public_html_old
ln -s ~/laravel-app/public ~/public_html
Option C: copy public/ into public_html (last resort)
If neither is possible, copy the contents of public/ into public_html and edit public_html/index.php so its paths point to the app folder:
require __DIR__.'/../laravel-app/vendor/autoload.php';
$app = require_once __DIR__.'/../laravel-app/bootstrap/app.php';
Also check maintenance.php in the same file. This works, but every deploy now has to update two places, so we treat it as a fallback.
Configure .env for production
Create .env on the server from .env.example. Never commit it and never upload your local one unchanged.
APP_NAME="Your App"
APP_ENV=production
APP_DEBUG=false
APP_URL=https://example.com
DB_CONNECTION=mysql
DB_HOST=localhost
DB_PORT=3306
DB_DATABASE=youruser_appdb
DB_USERNAME=youruser_appuser
DB_PASSWORD=use-a-long-random-password
LOG_CHANNEL=daily
SESSION_DRIVER=database
QUEUE_CONNECTION=database
Then generate the key once:
php artisan key:generate
APP_DEBUG=false is not optional. With debug on, an error page can show environment variables, including your database password.
Set up MySQL in cPanel
- Open MySQL Databases (or the Database Wizard).
- Create a database. cPanel prefixes it with your account name, for example
youruser_appdb. - Create a user with a strong password, also prefixed.
- Add the user to the database and grant only the privileges the app needs. For most apps that means data and schema privileges, not everything.
- Use
localhostasDB_HOSTunless your host documents otherwise.
Run migrations over SSH:
cd ~/laravel-app
php artisan migrate --force
The --force flag is required in production because Laravel asks for confirmation otherwise. Take a database backup before running migrations on a live site.
Storage permissions and storage:link
Laravel writes to storage/ and bootstrap/cache/. On most cPanel servers PHP runs as your own user, so standard permissions are enough:
cd ~/laravel-app
find storage bootstrap/cache -type d -exec chmod 755 {} \;
find storage bootstrap/cache -type f -exec chmod 644 {} \;
Do not set 777. If writes fail with 755, the host is running PHP as a different user, and you should ask support rather than opening permissions to everyone.
If the app serves user uploads from the public disk, create the symlink:
php artisan storage:link
Some shared hosts disable PHP’s symlink() function. In that case create the link manually with ln -s ~/laravel-app/storage/app/public ~/laravel-app/public/storage, or ask support.
Optimise for production
After each deploy, cache configuration, routes, views and events:
composer install --no-dev --optimize-autoloader
php artisan optimize
php artisan optimize caches config, routes, views and events in one step. Remember that once config is cached, changes to .env are ignored until you run it again (or php artisan optimize:clear).
Run the scheduler with cron
Laravel’s scheduler needs one cron entry that runs every minute. In cPanel, open Cron Jobs and add:
* * * * * cd /home/youruser/laravel-app && /usr/local/bin/php artisan schedule:run >> /dev/null 2>&1
The PHP path varies between hosts. Run which php over SSH, or check your host’s docs, and make sure the CLI PHP version matches the one your site uses. A frequent surprise is a web version of 8.3 while cron quietly runs an older CLI binary.
Shared hosting rarely allows long-running processes, so a permanent queue:work daemon is usually not an option. A practical workaround is to process the queue from the scheduler:
// routes/console.php
use Illuminate\Support\Facades\Schedule;
Schedule::command('queue:work --stop-when-empty --max-time=50')
->everyMinute()
->withoutOverlapping();
SSH vs FTP deploy
| SSH (git or rsync) | FTP / FTPS | |
|---|---|---|
| Speed | Fast, only changed files | Slow for vendor/ with thousands of files |
| Run artisan and composer | Yes | No, needs a workaround |
| Atomic-ish releases | Possible with release folders and a symlink | No, files change one by one |
| Availability | Needs SSH enabled | Works on almost every host |
| Our preference | Yes | Only when SSH is not available |
With SSH, a simple deploy looks like this:
cd ~/laravel-app
php artisan down
git pull origin main
composer install --no-dev --optimize-autoloader
php artisan migrate --force
php artisan optimize
php artisan up
With FTP only, build the app in CI (including vendor/ and compiled front-end assets), upload it with an FTP deploy action that syncs only changed files, and exclude .env and storage/ so they survive each deploy. Migrations then need either a one-off SSH session, the cPanel terminal, or a protected route you remove afterwards. We explain the CI side in push-to-deploy with GitHub Actions.
Common errors and fixes
500 error with a blank page. Check storage/logs/laravel.log and the cPanel Errors page. Usually it is permissions, a missing vendor/ folder, or the wrong PHP version.
“No application encryption key has been specified”. Run php artisan key:generate, then php artisan optimize if config is cached.
Changes to .env do nothing. Config is cached. Run php artisan optimize:clear then php artisan optimize.
404 on every route except the home page. The .htaccess file from public/ is missing or mod_rewrite rules are not applied. Make sure public/.htaccess was uploaded (hidden files are easy to skip in FTP clients).
“SQLSTATE[HY000] [1045] Access denied”. Wrong database name or user, usually because the cPanel prefix is missing, or the user was never added to the database.
CSS and JS return 404. Front-end assets were not built. Run npm run build locally or in CI and upload public/build.
Uploaded images do not show. storage:link was not created, or it points to a local path from your laptop.
Scheduler never runs. Wrong PHP binary or path in the cron entry. Temporarily log output to a file instead of /dev/null to see the error.
Key takeaways
- App folder outside
public_html; onlypublic/is web-accessible - PHP version and extensions match Laravel’s requirements
- Production
.envcreated on the server withAPP_DEBUG=false - Database and user created with prefixes and limited privileges
-
storage/andbootstrap/cache/writable without777 -
storage:linkin place if you serve uploads -
php artisan optimizeafter each deploy - One cron entry for
schedule:run, with the correct PHP binary - SSH deploys where possible; FTP deploys exclude
.envandstorage/ - Backup before every migration
Shared hosting is a perfectly reasonable home for many Laravel apps, as long as it is set up with the same care as a VPS. If your host’s setup is fighting you, or a deploy has left the site down, our cPanel hosting support team can sort out the layout, cron and deploy pipeline with you. Contact us with your hosting details and we will take a look.