Cómo Firmar PDFs Usando Certificados Digitales en Laravel, PHP y Symfony

Introducción

En esta guía aprenderás a firmar PDFs digitalmente usando PHP, Laravel y Symfony. Para simplificar el proceso, usaremos pdfsignify.com, una API que permite enviar un PDF, un certificado digital y su contraseña para obtener como resultado un documento firmado.
Las firmas digitales permiten verificar la autenticidad e integridad de un PDF. Para firmar un documento, necesitarás un certificado en formato .pfx o .p12, junto con la contraseña asociada al certificado.

Parte 1: Configuración de una Aplicación Laravel

Primero configuraremos una aplicación Laravel que se encargará de leer el PDF, cargar el certificado y realizar la solicitud a la API de PDF Signify. Si ya tienes un proyecto Laravel creado, puedes saltarte esta parte.
Crea un nuevo proyecto Laravel usando Composer:
Copy
composer create-project --prefer-dist laravel/laravel pdf-signing-app

Paso 1: Configurar el entorno de Laravel

Copy
cd pdf-signing-app
cp .env.example .env
php artisan key:generate

Paso 2: Configurar la base de datos

Abre el archivo .env y configura la conexión a tu base de datos. En este ejemplo usamos MySQL.
Copy
DB_CONNECTION=mysql
DB_HOST=127.0.0.1
DB_PORT=3306
DB_DATABASE=nombre_de_tu_base_de_datos
DB_USERNAME=nombre_de_usuario_de_tu_base_de_datos
DB_PASSWORD=contraseña_de_tu_base_de_datos

Paso 3: Ejecutar las migraciones

Copy
php artisan migrate

Parte 2: Configuración de PDF Signify

Ahora configuraremos PDF Signify para poder firmar los documentos. Desde el panel podrás crear proyectos, gestionar límites de uso y generar las credenciales necesarias para autenticar las solicitudes.

Paso 1: Crear una cuenta o iniciar sesión

Entra en pdfsignify.com y crea una cuenta. También puedes iniciar sesión si ya tienes una.
Tutorial image description
Puedes registrarte manualmente o usando una cuenta de Google o Microsoft.
Tutorial image description

Paso 2: Verificar el correo electrónico

Después del registro, revisa tu bandeja de entrada y confirma tu correo electrónico usando el enlace de verificación.
Tutorial image description
Tutorial image description
Si no recibes el correo, revisa la carpeta de spam o solicita un nuevo email de verificación.
Tutorial image description

Paso 3: Crear un proyecto

Una vez dentro del panel, crea un nuevo proyecto. Esto te permitirá generar claves API, controlar el uso y gestionar las firmas digitales.
Tutorial image description
Selecciona un plan y asigna un nombre al proyecto.
Tutorial image description

Paso 4: Crear credenciales API

Ve a la sección API Credentials y pulsa en Create Credentials. Se generará una clave de acceso y una clave secreta.
Tutorial image description
Guarda la Secret Key en un lugar seguro. Normalmente solo se muestra una vez, así que si la pierdes tendrás que regenerar las credenciales.
Tutorial image description

Parte 3: Preparar el PDF y el Certificado

Para este ejemplo usaremos un PDF ya creado. Guarda el PDF dentro de storage/app/ con el nombre filepdf.pdf.
Copy
laravel-project/
│
├── app/
├── bootstrap/
├── config/
├── database/
├── public/
├── resources/
├── routes/
├── storage/
│   └── app/
│       └── filepdf.pdf
├── tests/
└── artisan
Después, guarda tu certificado digital en el mismo directorio. Puede estar en formato .pfx o .p12. En este ejemplo usaremos certificate.pfx.
Copy
laravel-project/
│
├── storage/
│   └── app/
│       ├── filepdf.pdf
│       └── certificate.pfx
└── artisan

Parte 4: Programar la Firma en Laravel / PHP

Paso 1: Crear la ruta

Abre el archivo routes/web.php y añade una ruta para firmar el PDF.
Copy
use App\Http\Controllers\PDFSignController;

Route::get('/sign-pdf', [PDFSignController::class, 'signPDF'])->name('sign.pdf');

Paso 2: Crear o editar el controlador

Crea el controlador PDFSignController:
Copy
php artisan make:controller PDFSignController
Puedes empezar con una función básica para comprobar que la ruta funciona:
Copy
<?php

namespace App\Http\Controllers;

use Illuminate\Support\Facades\Storage;
use CURLFile;

class PDFSignController extends Controller
{
    public function signPDF()
    {
        dd("Sign pdf");
    }
}

Paso 3: Leer el PDF, el certificado y la contraseña

Copy
$certPath = Storage::path('certificate.pfx');
$pdfPath = Storage::path('filepdf.pdf');
$password = "YOUR_CERTIFICATE_PASSWORD";

Paso 4: Crear los datos del formulario

La API espera una petición multipart/form-data con el certificado, la contraseña y el PDF.
Copy
$postFields = [
    'certificate' => new CURLFile($certPath, 'application/x-pkcs12', 'certificate.pfx'),
    'certificatePassword' => $password,
    'pdf' => new CURLFile($pdfPath, 'application/pdf', 'filepdf.pdf')
];

Paso 5: Enviar la solicitud con cURL

Copy
$ch = curl_init();

curl_setopt($ch, CURLOPT_URL, 'https://api.pdfsignify.com/api/v1/sign-pdf');
curl_setopt($ch, CURLOPT_POST, 1);
curl_setopt($ch, CURLOPT_POSTFIELDS, $postFields);
curl_setopt($ch, CURLOPT_RETURNTRANSFER, true);

$headers = [
    'Content-Type: multipart/form-data',
    'AccessKey: ' . "MY_ACCESS_KEY",
    'SecretKey: ' . "MY_SECRET_KEY"
];

curl_setopt($ch, CURLOPT_HTTPHEADER, $headers);

$response = curl_exec($ch);
$statusCode = curl_getinfo($ch, CURLINFO_HTTP_CODE);

curl_close($ch);

if ($statusCode != 200) {
    return response()->json(['response' => $response], 500);
}

return response($response, 200)
    ->header('Content-Type', 'application/pdf')
    ->header('Content-Disposition', 'attachment; filename="signed_filepdf.pdf"');
Reemplaza MY_ACCESS_KEY, MY_SECRET_KEY y YOUR_CERTIFICATE_PASSWORD por tus valores reales.

Paso 6: Controlador completo

Copy
<?php

namespace App\Http\Controllers;

use CURLFile;
use Illuminate\Support\Facades\Storage;

class PDFSignController extends Controller
{
    public function signPDF()
    {
        $certPath = Storage::path('certificate.pfx');
        $pdfPath = Storage::path('filepdf.pdf');
        $password = "YOUR_CERTIFICATE_PASSWORD";

        $postFields = [
            'certificate' => new CURLFile($certPath, 'application/x-pkcs12', 'certificate.pfx'),
            'certificatePassword' => $password,
            'pdf' => new CURLFile($pdfPath, 'application/pdf', 'filepdf.pdf')
        ];

        $ch = curl_init();

        curl_setopt($ch, CURLOPT_URL, 'https://api.pdfsignify.com/api/v1/sign-pdf');
        curl_setopt($ch, CURLOPT_POST, 1);
        curl_setopt($ch, CURLOPT_POSTFIELDS, $postFields);
        curl_setopt($ch, CURLOPT_RETURNTRANSFER, true);

        $headers = [
            'Content-Type: multipart/form-data',
            'AccessKey: ' . "MY_ACCESS_KEY",
            'SecretKey: ' . "MY_SECRET_KEY"
        ];

        curl_setopt($ch, CURLOPT_HTTPHEADER, $headers);

        $response = curl_exec($ch);
        $statusCode = curl_getinfo($ch, CURLINFO_HTTP_CODE);

        curl_close($ch);

        if ($statusCode != 200) {
            return response()->json(['response' => $response], 500);
        }

        return response($response, 200)
            ->header('Content-Type', 'application/pdf')
            ->header('Content-Disposition', 'attachment; filename="signed_filepdf.pdf"');
    }
}

Paso 7: Probar la ruta

Copy
php artisan serve
Abre http://localhost:8000/sign-pdf. Si todo está correcto, se descargará el PDF firmado.

Parte 5: Verificar el Resultado

Errores comunes

  • El PDF no está guardado con el nombre correcto.
  • El certificado no está en la ruta correcta o tiene una extensión incorrecta.
  • La contraseña del certificado no es válida.
  • Las credenciales de la API no son correctas.

Respuesta correcta

Si la solicitud funciona, recibirás un PDF firmado. Ten en cuenta que algunos navegadores no muestran bien la información de firma digital. Es mejor descargarlo y abrirlo con Adobe Reader u otro visor PDF completo.
Por defecto, la firma visible puede aparecer en la esquina inferior izquierda del documento.
Tutorial image description

Parte 6: Personalizar la Firma

PDF Signify permite personalizar la apariencia de la firma: posición, tamaño, página, mensaje, fecha, zona horaria, imagen y otros parámetros.

Paso 1: Imagen de fondo de la firma

Para usar una imagen de fondo puedes enviar el parámetro signatureBackgroundImage.
Copy
$signatureBackgroundImage = Storage::path('backgroundImage.png');

$postFields = [
    'certificate' => new CURLFile($certPath, 'application/x-pkcs12', 'certificate.pfx'),
    'certificatePassword' => $password,
    'pdf' => new CURLFile($pdfPath, 'application/pdf', 'filepdf.pdf'),
    'signatureBackgroundImage' => new CURLFile($signatureBackgroundImage, 'image/png', 'logo.png'),
];

Paso 2: Imagen cerca de la firma

También puedes usar el parámetro signatureImage para colocar una imagen junto a la firma.
Copy
$signatureImage = Storage::path('signatureImage.png');

$postFields = [
    'certificate' => new CURLFile($certPath, 'application/x-pkcs12', 'certificate.pfx'),
    'certificatePassword' => $password,
    'pdf' => new CURLFile($pdfPath, 'application/pdf', 'filepdf.pdf'),
    'signatureImage' => new CURLFile($signatureImage, 'image/png', 'logo.png'),
];

Paso 3: Parámetros de apariencia

Algunos parámetros útiles son signatureXPosition, signatureYPosition, signatureWidth, signatureHeight, signatureMessage y signaturePageAppearance.
Copy
$postFields['signaturePageAppearance'] = -1;
$postFields['timezone'] = 'UTC';
$postFields['signatureMessage'] = 'Digitally signed by the user';
$postFields['signatureDateLabel'] = '';
$postFields['signatureDateFormat'] = 'Y-m-d H:i:s';
$postFields['signatureHeight'] = 100;
$postFields['signatureWidth'] = 150;
$postFields['signatureYPosition'] = 100;
$postFields['signatureXPosition'] = 180;

Parte 7: Metadatos del PDF

Al firmar un PDF también puedes modificar sus metadatos, como título, autor, asunto, palabras clave, creador, productor y fechas de creación o modificación.
Copy
$postFields['pdfMetadataAuthor'] = 'AUTHOR';
$postFields['pdfMetadataKeywords'] = 'keywords,keyword2';
$postFields['pdfMetadataTitle'] = 'MY DOCUMENT';
$postFields['pdfMetadataSubject'] = 'EXAMPLE DOCUMENT';
$postFields['pdfMetadataCreator'] = 'PDFSIGNIFY';
$postFields['pdfMetadataProducer'] = 'PDFSIGNIFY';

$currentDateTime = (new \DateTime())->format('Y-m-d H:i:s');

$postFields['pdfMetadataCreationDate'] = $currentDateTime;
$postFields['pdfMetadataModificationDate'] = $currentDateTime;
También puedes usar el endpoint /api/v1/set-pdf-metadata si solo quieres cambiar los metadatos sin firmar el documento.

Parte 8: Ejemplo Completo con Personalización

Copy
<?php

namespace App\Http\Controllers;

use CURLFile;
use Illuminate\Support\Facades\Storage;

class PDFSignController extends Controller
{
    public function signPDF()
    {
        $certPath = Storage::path('certificate.pfx');
        $pdfPath = Storage::path('filepdf.pdf');
        $password = "password";

        $signatureBackgroundImage = Storage::path('backgroundImage.png');

        $postFields = [
            'certificate' => new CURLFile($certPath, 'application/x-pkcs12', 'certificate.pfx'),
            'certificatePassword' => $password,
            'pdf' => new CURLFile($pdfPath, 'application/pdf', 'filepdf.pdf'),
            'signatureBackgroundImage' => new CURLFile($signatureBackgroundImage, 'image/png', 'logo.png'),
        ];

        $postFields['signaturePageAppearance'] = -1;
        $postFields['timezone'] = 'UTC';
        $postFields['signatureMessage'] = 'Digitally signed by the user';
        $postFields['signatureDateLabel'] = '';
        $postFields['signatureDateFormat'] = 'Y-m-d H:i:s';
        $postFields['signatureHeight'] = 100;
        $postFields['signatureWidth'] = 150;
        $postFields['signatureYPosition'] = 100;
        $postFields['signatureXPosition'] = 180;

        $postFields['pdfMetadataAuthor'] = 'AUTHOR';
        $postFields['pdfMetadataKeywords'] = 'keywords,keyword2';
        $postFields['pdfMetadataTitle'] = 'MY DOCUMENT';
        $postFields['pdfMetadataSubject'] = 'EXAMPLE DOCUMENT';
        $postFields['pdfMetadataCreator'] = 'PDFSIGNIFY';
        $postFields['pdfMetadataProducer'] = 'PDFSIGNIFY';

        $currentDateTime = (new \DateTime())->format('Y-m-d H:i:s');

        $postFields['pdfMetadataCreationDate'] = $currentDateTime;
        $postFields['pdfMetadataModificationDate'] = $currentDateTime;

        $ch = curl_init();

        curl_setopt($ch, CURLOPT_URL, 'https://api.pdfsignify.com/api/v1/sign-pdf');
        curl_setopt($ch, CURLOPT_POST, 1);
        curl_setopt($ch, CURLOPT_POSTFIELDS, $postFields);
        curl_setopt($ch, CURLOPT_RETURNTRANSFER, true);

        $headers = [
            'Content-Type: multipart/form-data',
            'AccessKey: ' . "MY_ACCESS_KEY",
            'SecretKey: ' . "MY_SECRET_KEY"
        ];

        curl_setopt($ch, CURLOPT_HTTPHEADER, $headers);

        $response = curl_exec($ch);
        $statusCode = curl_getinfo($ch, CURLINFO_HTTP_CODE);

        curl_close($ch);

        if ($statusCode != 200) {
            return response()->json(['response' => $response], 500);
        }

        return response($response, 200)
            ->header('Content-Type', 'application/pdf')
            ->header('Content-Disposition', 'attachment; filename="signed_filepdf.pdf"');
    }
}

Parte 9: Verificar la Contraseña del Certificado

PDF Signify también permite comprobar si un certificado es válido y si la contraseña coincide usando el endpoint /api/v1/check-certificate-password.
Copy
Route::get('/check-certificate', [PDFSignController::class, 'checkCertificate'])->name('check.certificate');
Copy
public function checkCertificate()
{
    $certPath = Storage::path('certificate.pfx');
    $password = "password";

    $postFields = [
        'certificate' => new CURLFile($certPath, 'application/x-pkcs12', 'certificate.pfx'),
        'certificatePassword' => $password,
    ];

    $ch = curl_init();

    curl_setopt($ch, CURLOPT_URL, 'https://api.pdfsignify.com/api/v1/check-certificate-password');
    curl_setopt($ch, CURLOPT_POST, 1);
    curl_setopt($ch, CURLOPT_POSTFIELDS, $postFields);
    curl_setopt($ch, CURLOPT_RETURNTRANSFER, true);

    $headers = [
        'Content-Type: multipart/form-data',
        'AccessKey: ' . "MY_ACCESS_KEY",
        'SecretKey: ' . "MY_SECRET_KEY"
    ];

    curl_setopt($ch, CURLOPT_HTTPHEADER, $headers);

    $response = curl_exec($ch);

    curl_close($ch);

    return response()->json(['response' => $response]);
}
Una respuesta correcta puede ser similar a esta:
Copy
{
  "success": true,
  "message": "¡La contraseña del certificado es correcta!",
  "errors": [],
  "data": {},
  "auth": true
}
Si la contraseña no es correcta, la respuesta puede ser similar a esta:
Copy
{
  "success": false,
  "message": "¡La contraseña del certificado no es correcta!",
  "errors": [
    "certificatePassword"
  ],
  "data": {},
  "auth": true
}

Parte 10: Conclusiones

En este tutorial hemos visto cómo firmar PDFs usando Laravel, PHP o Symfony junto con PDF Signify. También hemos visto cómo personalizar la firma, modificar metadatos del PDF y validar la contraseña de un certificado digital.
Puedes consultar más información en estos recursos: