Pagination in PHP: Cursor-Based vs Offset Pagination

12 min read 2,303 words
Pagination in PHP: Cursor-Based vs Offset Pagination for Business Applications featured image

Why Automated Testing Matters for PHP Projects

Manual testing slows down development. Every time a developer finishes a feature, someone has to run through the same checks, often by hand. This works fine for small projects with a single developer, but it breaks down quickly as codebases grow and teams expand. Bugs slip through. Edge cases get missed. Deployment anxiety becomes the norm.

Automated testing through continuous integration solves this by running your test suite on every change. Before code reaches the main branch, it gets validated. If something breaks, you know immediately. This shift in how teams catch and fix problems makes a real difference in project stability and delivery speed.

If you are building business web applications with PHP, setting up a basic CI pipeline is one of the most practical improvements you can make. The tools are free for open repositories, and the setup does not require DevOps expertise.

What Continuous Integration Means for PHP Development

Continuous integration is a development practice where developers merge code changes into a shared repository frequently. Each merge triggers an automated build and test process. The goal is to detect problems early, improve software quality, and reduce the time it takes to validate changes.

For PHP projects, this typically means running a test suite powered by PHPUnit on every push or pull request. The pipeline installs dependencies, runs the tests, and reports the outcome. If tests fail, the pipeline fails. That feedback loop is the core of continuous integration.

GitHub Actions makes this straightforward because it integrates directly with GitHub repositories. You define your workflow in a YAML file, and GitHub handles the execution, caching, and reporting. No external CI server is required.

Setting Up GitHub Actions for a PHP Project

The workflow lives in a .github/workflows directory at the root of your repository. Each file in that directory represents a separate workflow. A minimal PHP CI workflow needs three things: a trigger event, a virtual machine to run on, and a set of steps.

name: PHP CI

on:
  push:
    branches: [ main, develop ]
  pull_request:
    branches: [ main ]

jobs:
  test:
    runs-on: ubuntu-latest

    steps:
      - name: Checkout code
        uses: actions/checkout@v4

      - name: Setup PHP
        uses: shivammathur/setup-php@v2
        with:
          php-version: '8.2'
          extensions: mbstring, xml, json
          coverage: xdebug

      - name: Install Composer dependencies
        run: composer install --no-interaction --prefer-dist

      - name: Run PHPUnit tests
        run: ./vendor/bin/phpunit --testdox

This workflow triggers on pushes to the main and develop branches, as well as on pull requests targeting main. It checks out the code, sets up PHP 8.2 with the extensions most PHP projects need, installs dependencies, and runs the test suite.

The shivammathur/setup-php action handles PHP installation and configuration. It also supports setting a specific coverage driver, which matters if you want to generate code coverage reports.

Adding a Database for Integration Tests

Many PHP applications depend on a database. Unit tests can mock the database layer, but integration tests often need a real database running. GitHub Actions supports service containers, which let you spin up a database alongside your test job.

jobs:
  test:
    runs-on: ubuntu-latest

    services:
      mysql:
        image: mysql:8.0
        env:
          MYSQL_ROOT_PASSWORD: root
          MYSQL_DATABASE: test_db
        ports:
          - 3306:3306
        options: >-
          --health-cmd="mysqladmin ping"
          --health-interval=10s
          --health-timeout=5s
          --health-retries=5

    steps:
      - name: Checkout code
        uses: actions/checkout@v4

      - name: Setup PHP
        uses: shivammathur/setup-php@v2
        with:
          php-version: '8.2'
          extensions: pdo, pdo_mysql

      - name: Install Composer dependencies
        run: composer install --no-interaction --prefer-dist

      - name: Run migrations and tests
        run: |
          php artisan migrate --force
          ./vendor/bin/phpunit --testdox

The MySQL service starts automatically and is accessible at localhost:3306. The health check ensures the database is ready before the test steps run. This pattern works for PostgreSQL, MariaDB, and other database engines by swapping the image and environment variables.

Caching Dependencies to Speed Up Pipelines

Every time your workflow runs, it downloads Composer packages from scratch. On larger projects, this adds noticeable time to each pipeline run. Composer supports caching, and GitHub Actions has built-in support for caching vendor directories.

      - name: Cache Composer dependencies
        uses: actions/cache@v3
        with:
          path: vendor
          key: composer-${{ hashFiles('**/composer.lock') }}
          restore-keys: |
            composer-

      - name: Install Composer dependencies
        run: composer install --no-interaction --prefer-dist --no-progress

The cache key is based on the hash of your composer.lock file. When the lock file changes, the cache misses and Composer downloads fresh dependencies. Otherwise, the vendor directory restores from cache, cutting installation time significantly.

For projects with npm or yarn dependencies, the same caching approach applies to the node_modules directory. Combining both caches can reduce pipeline run time from several minutes down to under a minute on subsequent runs.

Running Multiple PHP Versions and Configuration Tests

PHP projects often need to support multiple PHP versions. If you maintain a library or a plugin that others use, testing against PHP 7.4, 8.0, 8.1, and 8.2 catches compatibility issues before your users encounter them. A matrix strategy makes this straightforward.

jobs:
  test:
    runs-on: ubuntu-latest

    strategy:
      fail-fast: false
      matrix:
        php-version: ['7.4', '8.0', '8.1', '8.2']
        dependencies: [lowest, current]

    steps:
      - name: Checkout code
        uses: actions/checkout@v4

      - name: Setup PHP
        uses: shivammathur/setup-php@v2
        with:
          php-version: ${{ matrix.php-version }}

      - name: Validate composer.json and composer.lock
        run: composer validate

      - name: Install dependencies
        run: |
          composer require --no-interaction ${{ matrix.dependencies }} --no-progress
        env:
          COMPOSER dependencies: ${{ matrix.dependencies }}

      - name: Run PHPUnit tests
        run: ./vendor/bin/phpunit --testdox

The matrix generates twelve separate jobs, one for each combination of PHP version and dependency constraint. The lowest constraint installs the minimum required versions, while current installs the latest. This catches issues where your code works with the latest packages but breaks with older compatible versions.

Setting fail-fast: false ensures all matrix jobs complete even if one fails, giving you a complete picture of compatibility across all combinations.

Automating Code Quality Checks

Tests alone are not enough. Code style violations, static analysis warnings, and security vulnerabilities can slip through even when all tests pass. Adding linting and static analysis to your pipeline raises the overall quality bar.

      - name: Run PHPStan static analysis
        run: ./vendor/bin/phpstan analyse src --level=max

      - name: Check code style
        run: ./vendor/bin/phpcs --standard=PSR12 src

PHPStan and PHP CodeSniffer are standard tools in professional PHP development. PHPStan performs static analysis without running the code, catching type errors, undefined variables, and other issues that tests might miss. PHP CodeSniffer enforces coding standards, keeping the codebase consistent across contributors.

You can also add tools like Psalm, PHP Insights, or Rector depending on your project needs. The key is integrating these checks into the CI pipeline so they run automatically on every change rather than relying on developers to remember to run them.

Deployment Pipelines and Environment Configuration

Once your tests pass, you can extend the workflow to deploy automatically. For example, you might deploy to a staging server on merges to the develop branch and to production on releases. GitHub Actions supports environment protection rules, which require manual approval before deployment to production.

jobs:
  deploy:
    runs-on: ubuntu-latest
    needs: test
    if: github.ref == 'refs/heads/main'

    environment:
      name: production
      url: https://yourapp.com

    steps:
      - name: Deploy to production
        run: |
          # Your deployment commands here
          echo "Deploying to production"

The needs: test dependency ensures deployment only runs if the test job succeeds. The if condition restricts the trigger to the main branch, preventing accidental deployments from feature branches.

For deployment, store credentials as GitHub Secrets and reference them in the workflow. Never hardcode tokens, passwords, or API keys in your workflow files.

Security Considerations for CI Workflows

Workflow files have access to secrets and can perform privileged actions. This makes them a target for supply chain attacks. A malicious actor who gains push access to a repository can modify workflow files to extract secrets or exfiltrate code.

Protect your workflows by reviewing changes to .github/workflows carefully. Use the pull_request_target event carefully, as it runs with the base branch permissions rather than the head branch. For public repositories, be cautious with pull request workflows from forks, as they may have reduced permissions but still pose risks.

Limit secret access to specific jobs and steps using environment protection rules. Regularly audit which workflows have access to which secrets, and remove access when it is no longer needed.

For applications that handle sensitive data, a separate security review step in your pipeline can catch configuration issues before deployment. Tools like the OWASP Top 10 guide help identify common vulnerability patterns in web applications.

What Belongs in a CI Pipeline for PHP Projects

Not every check needs to run on every push. A practical CI pipeline for a business web application usually covers the essentials without becoming bloated.

  • Dependency installation: Composer install with a clean lock file validation step.
  • Unit and integration tests: PHPUnit covering business logic and database interactions.
  • Code style checks: PHP CodeSniffer to enforce coding standards.
  • Static analysis: PHPStan at an appropriate level for your codebase maturity.
  • Security checks: Composer audit to flag known vulnerabilities in dependencies.

Extending beyond these basics makes sense when your project has specific needs. A payment processing module might need additional security testing. An API-first application might benefit from contract testing. Keep the pipeline focused on what adds value for your specific project.

When to Move Beyond Basic CI Setup

A basic pipeline handles most PHP projects well. As your application grows, you might outgrow simple test runs. Larger teams benefit from trunk-based development with feature flags rather than long-lived feature branches. Complex architectures with microservices require different testing strategies, including contract testing and separate deployment pipelines for each service.

If you are building a more complex system, exploring API design principles alongside your CI setup helps. A well-designed API is easier to test, and good tests document the expected behaviour of your endpoints. The combination of automated testing and thoughtful API design makes it easier to maintain and extend your application over time.

For teams scaling their PHP infrastructure, comparing hosting options and understanding cloud platform differences matters. Different providers offer different tools for deployment, scaling, and monitoring, and your CI pipeline can integrate with those tools to create a more complete development and operations workflow.

Related practical reading

These related guides can help you connect this topic with the wider website, server, security, and support decisions around it.

Building a CI Foundation That Scales

A well-configured GitHub Actions pipeline catches bugs early, enforces code quality, and gives your team confidence when merging changes. Starting with a minimal setup covering dependency installation, test execution, and basic code quality checks is enough for most PHP projects. From there, you can extend the pipeline as your application grows.

The investment in setting up automated testing pays back quickly through reduced manual effort, fewer production bugs, and faster code review cycles. Even small projects benefit from the consistency and reliability that CI brings to the development process.

If you are setting up CI for a PHP project and want a practical review of your current configuration, gather your repository details, existing workflow files if any, and a summary of your testing setup before getting in touch.

Frequently Asked Questions

What PHP versions should I test against in a CI pipeline?
Test against the PHP versions your application supports. If you maintain a library used by others, test against all actively supported PHP versions including security branches. For typical business applications, testing the current stable version and the version currently in use by your deployment environment covers most cases.
How do I handle API keys and secrets in GitHub Actions?
Store sensitive values as GitHub Secrets and reference them using the ${{ secrets.SECRET_NAME }} syntax in your workflow files. Never commit secrets to the repository or include them in workflow files directly. Use environment variables to pass secrets to steps rather than passing them as command-line arguments, which can leak into logs.
Can I run CI pipelines on private repositories for free?
GitHub Actions offers free minutes and storage for private repositories within certain limits. The free tier includes 2,000 minutes per month for public repositories and 2,000 minutes per month for private repositories on free plans. Paid plans increase these limits. For small to medium projects, the free tier is usually sufficient to get started with automated testing.
My pipeline is slow. How do I speed it up?
Start by caching dependencies and any build artifacts. Use the most recent Ubuntu runner image, as it has faster network access to package repositories. Parallelise independent jobs using a matrix strategy. Consider splitting your pipeline into separate jobs that run concurrently rather than one long sequential job. For very large test suites, run only the tests affected by a change using tools that calculate test impact.
Should I run tests on pull requests or only after merging?
Running tests on pull requests catches problems before they reach the main branch. This prevents broken code from being merged and keeps the main branch in a deployable state. For long-running test suites, you might skip certain checks on every push and require them only before merging, but running tests on pull requests is the recommended baseline.
How do I integrate Composer audit into my pipeline?
Composer includes a built-in audit command that checks your dependencies against the FriendsOfPHP security advisory database. You can add it as a step in your workflow: