Security 15 min read

PHP Security Audit: A Developer's Guide Beyond Laravel

A comprehensive PHP security audit guide covering dependency scanning, php.ini hardening, input validation, common vulnerability classes, static analysis tools, and web server configuration.

Matt King
Matt King
May 14, 2026
Last updated: May 14, 2026
PHP Security Audit: A Developer's Guide Beyond Laravel

Most PHP security content focuses on Laravel or Symfony specifically. But PHP powers roughly 75% of websites with a known server-side language, and a huge portion of that is vanilla PHP, WordPress, custom frameworks, or older applications that do not benefit from a modern framework's built-in protections.

This guide covers PHP security auditing from the ground up. Whether you are running Laravel, Symfony, CakePHP, or plain PHP scripts behind Apache, these fundamentals apply.


1. Dependency Auditing with Composer

If your project uses Composer (and it should), dependency auditing is the fastest security win available. Known vulnerabilities in third-party packages are the most common attack vector for PHP applications.

Running composer audit

Since Composer 2.4, the audit command is built in:

composer audit

This checks your composer.lock file against the PHP Security Advisories Database and reports known vulnerabilities:

Found 2 security vulnerability advisories affecting 2 packages:
+-------------------+--------------------------+
| Package           | CVE                      |
+-------------------+--------------------------+
| guzzlehttp/guzzle | CVE-2023-29197           |
| symfony/http-kern | CVE-2024-51996           |
+-------------------+--------------------------+

Automating Dependency Checks

Add composer audit to your CI pipeline. Here is a GitHub Actions example:

# .github/workflows/security.yml
name: Security Audit
on:
  push:
    branches: [main]
  pull_request:
  schedule:
    - cron: '0 6 * * 1'  # Weekly Monday morning

jobs:
  audit:
    runs-on: ubuntu-latest
    steps:
      - uses: actions/checkout@v4
      - uses: shivammathur/setup-php@v2
        with:
          php-version: '8.3'
      - run: composer install --no-dev
      - run: composer audit --format=json

The --format=json flag produces machine-readable output for integration with alerting systems.

Beyond composer audit

For deeper dependency analysis, consider additional tools:

# Roave security advisories - prevents installing packages with known vulnerabilities
composer require --dev roave/security-advisories:dev-latest

# Local PHP Security Checker (Symfony)
composer global require enlightn/security-checker
security-checker security:check /path/to/composer.lock

For a deeper look at managing Composer dependencies securely, see our Composer vulnerability management guide.


2. PHP Configuration Security (php.ini)

Your php.ini settings are a security boundary. Misconfigured PHP exposes information, enables dangerous functions, and widens your attack surface.

Essential php.ini Settings

; PRODUCTION php.ini security settings

; Never expose PHP version in headers
expose_php = Off

; Never display errors to users
display_errors = Off
display_startup_errors = Off

; Log errors instead of displaying them
log_errors = On
error_log = /var/log/php/error.log

; Restrict file operations to specific directories
open_basedir = /var/www/html:/tmp

; Disable dangerous functions
disable_functions = exec,passthru,shell_exec,system,proc_open,popen,curl_multi_exec,parse_ini_file,show_source

; Disable remote file inclusion
allow_url_include = Off
allow_url_fopen = Off  ; Set to On only if your app needs it (e.g., API calls)

; Session security
session.cookie_httponly = 1
session.cookie_secure = 1
session.use_strict_mode = 1
session.use_only_cookies = 1

; Limit upload size
upload_max_filesize = 2M
post_max_size = 8M

; Limit execution resources
max_execution_time = 30
max_input_time = 60
memory_limit = 128M

Checking Your Current Configuration

Create a quick audit script (run it locally, never deploy it):

<?php
// security-audit.php - Run locally, never deploy to production

$checks = [
    'expose_php' => ['expected' => '0', 'risk' => 'Leaks PHP version in headers'],
    'display_errors' => ['expected' => '0', 'risk' => 'Exposes error details to attackers'],
    'allow_url_include' => ['expected' => '0', 'risk' => 'Enables remote file inclusion'],
    'session.cookie_httponly' => ['expected' => '1', 'risk' => 'Session cookies accessible via JavaScript'],
    'session.cookie_secure' => ['expected' => '1', 'risk' => 'Session cookies sent over HTTP'],
    'session.use_strict_mode' => ['expected' => '1', 'risk' => 'Accepts uninitialized session IDs'],
];

foreach ($checks as $directive => $check) {
    $actual = ini_get($directive);
    $status = ($actual == $check['expected']) ? 'PASS' : 'FAIL';
    echo sprintf("[%s] %s = %s (expected: %s) - %s\n",
        $status, $directive, $actual, $check['expected'], $check['risk']
    );
}

// Check disabled functions
$disabled = ini_get('disable_functions');
$dangerous = ['exec', 'system', 'passthru', 'shell_exec', 'proc_open'];
$enabled = array_filter($dangerous, fn($f) => !str_contains($disabled, $f));
if (!empty($enabled)) {
    echo "[FAIL] Dangerous functions still enabled: " . implode(', ', $enabled) . "\n";
} else {
    echo "[PASS] All dangerous process functions are disabled\n";
}

open_basedir: Your Filesystem Boundary

open_basedir restricts PHP file operations to specified directories. Without it, a file inclusion vulnerability can read any file on the system:

// Without open_basedir, an attacker exploiting a file inclusion bug can read:
include('/etc/passwd');           // System files
include('/var/www/other-site/config.php');  // Other applications

With open_basedir set:

open_basedir = /var/www/myapp:/tmp

PHP will refuse to open files outside /var/www/myapp and /tmp, regardless of the vulnerability.


3. Input Validation and Output Encoding

Every PHP security audit must review how user input flows through the application. The core principle: validate input, encode output.

Input Validation Patterns

// WRONG: Trusting user input
$id = $_GET['id'];
$query = "SELECT * FROM users WHERE id = $id";  // SQL injection

// RIGHT: Type casting
$id = (int) $_GET['id'];

// RIGHT: Prepared statements (PDO)
$stmt = $pdo->prepare('SELECT * FROM users WHERE id = :id');
$stmt->execute(['id' => $_GET['id']]);

// RIGHT: Validation with filter functions
$email = filter_input(INPUT_POST, 'email', FILTER_VALIDATE_EMAIL);
if ($email === false) {
    // Invalid email, reject the input
}

// Validate and sanitize file paths
$filename = basename($_GET['file']);  // Strip directory traversal
$path = realpath('/var/www/uploads/' . $filename);
if ($path === false || !str_starts_with($path, '/var/www/uploads/')) {
    // Path traversal attempt
    abort(403);
}

Output Encoding

Every time you render user-supplied data, encode it for the output context:

// HTML context
echo htmlspecialchars($userInput, ENT_QUOTES | ENT_HTML5, 'UTF-8');

// JavaScript context
echo json_encode($userInput, JSON_HEX_TAG | JSON_HEX_APOS | JSON_HEX_QUOT | JSON_HEX_AMP);

// URL context
echo urlencode($userInput);

// CSS context (avoid embedding user data in CSS entirely)

In Laravel, Blade's {{ }} syntax handles HTML encoding automatically. In Symfony, Twig's {{ }} does the same. In vanilla PHP, you must call htmlspecialchars() every single time.


4. Common PHP Vulnerability Classes

A thorough audit checks for these vulnerability patterns:

File Inclusion (LFI/RFI)

// VULNERABLE: User controls which file is included
$page = $_GET['page'];
include("pages/$page.php");
// Attack: ?page=../../../etc/passwd%00

// SAFE: Whitelist allowed values
$allowed = ['home', 'about', 'contact'];
$page = in_array($_GET['page'], $allowed, true) ? $_GET['page'] : 'home';
include("pages/$page.php");

Object Injection via unserialize()

// VULNERABLE: Deserializing user input
$data = unserialize($_COOKIE['preferences']);

// SAFE: Use JSON instead
$data = json_decode($_COOKIE['preferences'], true);

// If you must use unserialize, restrict allowed classes
$data = unserialize($input, ['allowed_classes' => [AllowedClass::class]]);

Type Juggling

PHP's loose comparison operator (==) produces surprising results that attackers exploit:

// VULNERABLE: Loose comparison for authentication
if ($user->token == $_GET['token']) {
    // Authenticated
}
// Attack: If token is "0e123456", the string "0e999999" also matches
// because PHP treats both as scientific notation equal to 0

// SAFE: Strict comparison
if (hash_equals($user->token, $_GET['token'])) {
    // Authenticated
}

Insecure Deserialization

Beyond unserialize(), watch for:

// Check for dangerous magic methods in your classes
class CacheItem {
    public $file;

    // This gets called during deserialization
    public function __wakeup() {
        include($this->file);  // Attacker controls $this->file
    }
}

Audit all __wakeup(), __destruct(), __toString(), and __call() methods for dangerous operations.


5. Static Analysis Tools

Automated tools catch patterns that manual review misses.

PHPStan

PHPStan catches type errors, undefined variables, and logic bugs at the highest levels:

composer require --dev phpstan/phpstan
vendor/bin/phpstan analyse src --level=max

For security-specific rules, add the phpstan-strict-rules extension:

composer require --dev phpstan/phpstan-strict-rules

Psalm

Psalm includes taint analysis that tracks user input through your code:

composer require --dev vimeo/psalm
vendor/bin/psalm --init
vendor/bin/psalm --taint-analysis

Taint analysis is Psalm's most security-relevant feature. It traces data from sources (like $_GET, $_POST) through your code to sinks (like echo, query(), include()) and flags unescaped paths:

ERROR: TaintedHtml
  echo $_GET['name'];
  The tainted value $_GET['name'] is output as HTML without escaping

Snyk

Snyk scans both your dependencies and your code for known vulnerabilities:

npm install -g snyk
snyk test --all-projects
snyk code test  # Static analysis

Combining Tools in CI

# .github/workflows/security-analysis.yml
jobs:
  static-analysis:
    runs-on: ubuntu-latest
    steps:
      - uses: actions/checkout@v4
      - uses: shivammathur/setup-php@v2
        with:
          php-version: '8.3'
      - run: composer install
      - run: vendor/bin/phpstan analyse --error-format=github
      - run: vendor/bin/psalm --taint-analysis --output-format=github
      - run: composer audit

6. Web Server Hardening for PHP

Your PHP application is only as secure as the web server in front of it.

Nginx Configuration

server {
    listen 443 ssl;
    server_name yourdomain.com;

    root /var/www/app/public;
    index index.php;

    # Security headers
    add_header X-Frame-Options "SAMEORIGIN" always;
    add_header X-Content-Type-Options "nosniff" always;
    add_header X-XSS-Protection "0" always;  # Disabled; use CSP instead
    add_header Referrer-Policy "strict-origin-when-cross-origin" always;
    add_header Strict-Transport-Security "max-age=31536000; includeSubDomains" always;

    # Block access to sensitive files
    location ~ /\.(env|git|htaccess) {
        deny all;
        return 404;
    }

    # Block access to composer files
    location ~ /(composer\.(json|lock)|vendor/) {
        deny all;
        return 404;
    }

    # Only pass .php files in the public directory to PHP-FPM
    location ~ \.php$ {
        # Prevent execution of uploaded PHP files
        try_files $uri =404;

        fastcgi_pass unix:/var/run/php/php8.3-fpm.sock;
        fastcgi_param SCRIPT_FILENAME $document_root$fastcgi_script_name;
        include fastcgi_params;

        # Hide PHP version
        fastcgi_hide_header X-Powered-By;
    }

    # Block direct access to storage/upload directories
    location ~* ^/uploads/.*\.php$ {
        deny all;
    }
}

Apache Configuration

<VirtualHost *:443>
    ServerName yourdomain.com
    DocumentRoot /var/www/app/public

    # Security headers
    Header always set X-Frame-Options "SAMEORIGIN"
    Header always set X-Content-Type-Options "nosniff"
    Header always set Strict-Transport-Security "max-age=31536000; includeSubDomains"
    Header always set Referrer-Policy "strict-origin-when-cross-origin"

    # Hide server version
    ServerTokens Prod
    ServerSignature Off
    Header unset X-Powered-By

    # Block access to sensitive files
    <FilesMatch "^\.">
        Require all denied
    </FilesMatch>

    <FilesMatch "(composer\.(json|lock))$">
        Require all denied
    </FilesMatch>

    # Prevent PHP execution in uploads
    <Directory /var/www/app/public/uploads>
        php_admin_flag engine off
    </Directory>
</VirtualHost>

PHP-FPM Security

; /etc/php/8.3/fpm/pool.d/www.conf

; Run as a non-root user
user = www-data
group = www-data

; Limit processes to prevent resource exhaustion
pm.max_children = 50
pm.start_servers = 5
pm.min_spare_servers = 5
pm.max_spare_servers = 10

; Restart workers after handling N requests (prevents memory leaks)
pm.max_requests = 500

; Restrict to socket (no TCP exposure)
listen = /var/run/php/php8.3-fpm.sock
listen.owner = www-data
listen.group = www-data
listen.mode = 0660

; Security restrictions
php_admin_value[open_basedir] = /var/www/app:/tmp
php_admin_flag[expose_php] = off
php_admin_flag[display_errors] = off

7. Framework-Specific Considerations

Laravel

Laravel handles most of the input validation, output encoding, and CSRF protection automatically. Focus your audit on:

  • Custom Blade directives using {!! !!} (unescaped output)
  • Raw database queries using DB::raw() or whereRaw()
  • File upload handling and storage configuration
  • API authentication (Sanctum/Passport token management)
  • Queue job payload validation

For a Laravel-specific audit checklist, see our Laravel security audit guide.

Symfony

Symfony provides strong defaults through the Security component:

  • Review security.yaml access control rules
  • Check Twig templates for |raw filter usage
  • Audit custom voters and authentication logic
  • Verify CSRF protection on all forms

Vanilla PHP

Without a framework, you carry full responsibility:

  • Every database query must use prepared statements
  • Every output must be encoded for its context
  • CSRF tokens must be implemented manually
  • Session handling must follow secure defaults
  • File uploads need manual validation and restricted storage

8. Why External Scanning Matters

Static analysis and code review catch code-level vulnerabilities. But they cannot verify what your application actually exposes to the internet after deployment:

  • Are your security headers present in production responses?
  • Is expose_php actually off, or did a php.ini override re-enable it?
  • Are debug pages accessible from the outside?
  • Is your .env file accessible via the web server?

External scanning bridges this gap by testing your application the way an attacker would: from the outside.

While StackShield specializes in Laravel, the external scanning methodology applies to any PHP application. It checks for exposed debug modes, missing security headers, information leakage, and configuration errors that only manifest in production.

Try a free scan on your PHP application to see what is visible from the outside.

Free security check

Is your Laravel app exposed right now?

34% of Laravel apps we scan have at least one critical issue. Most teams don't find out until something breaks. Our free scan checks your live application in under 60 seconds.

18% have debug mode on
72% missing security headers
12% have exposed .env
Scan My App Free No signup required. Results in 60 seconds.

Frequently Asked Questions

How do I run a PHP security audit?

A PHP security audit covers several layers: dependency scanning with composer audit, PHP configuration review (php.ini settings like expose_php, display_errors, disable_functions), code-level analysis using static analysis tools like PHPStan or Psalm, input validation and output encoding review, web server hardening, and external vulnerability scanning. Start with automated tools to identify low-hanging fruit, then perform manual review of authentication, authorization, and data handling logic.

What is composer audit and how does it work?

composer audit is a built-in Composer command (available since Composer 2.4) that checks your installed dependencies against the PHP Security Advisories Database. It reports known vulnerabilities in your project's packages along with CVE identifiers and severity levels. Run it with composer audit in your project directory, or add it to your CI pipeline to catch vulnerable dependencies automatically.

What PHP functions should be disabled in production?

Functions commonly disabled in production php.ini include exec, passthru, shell_exec, system, proc_open, popen, curl_multi_exec, parse_ini_file, show_source, eval (though eval is a language construct and cannot be disabled via disable_functions). The exact list depends on your application's requirements. If your code does not need to execute shell commands, disable all process execution functions.

What are the most common PHP vulnerability types?

The most common PHP vulnerability classes are SQL injection, cross-site scripting (XSS), file inclusion (local and remote), object injection via unserialize(), type juggling in loose comparisons, insecure deserialization, server-side request forgery (SSRF), and path traversal. Modern frameworks mitigate many of these by default, but custom code and legacy applications remain vulnerable without explicit protections.

Related Security Terms

Stay Updated on Laravel Security

Get actionable security tips, vulnerability alerts, and best practices for Laravel apps.