Curso de PHP desde cero, en español

PHP es el lenguaje que funciona en el servidor detrás de WordPress, Drupal, Laravel o Magento: según W3Techs, lo usa el 69 % de los sitios web cuyo lenguaje de servidor se conoce. Es fácil de empezar, porque basta un archivo y una orden, y a la vez es el lenguaje que más ataques recibe en internet, precisamente porque está en todas partes. Este curso empieza desde cero, enseña PHP moderno y pone la seguridad en cada lección, no en un apéndice. Todo el código está comprobado con PHP 8.5, la versión actual.

La ruta: ocho lecciones

Están pensadas para leerse en orden. Las dos primeras están en esta misma página y las demás tienen la suya. Cada lección tiene un recuadro «El error que te ahorra», con un fallo que PHP detecta o avisa si lo configuras bien, un recuadro «Ojo con la seguridad» con el fallo típico ligado a ese tema, al menos un esquema y retos al final con la solución escondida. Al terminar hay un test de 24 preguntas: adivinar qué imprime cada fragmento.

Lección 1. Primeros pasos

Qué es PHP

PHP nació en 1994 como un puñado de programas en C que Rasmus Lerdorf escribió para contar las visitas a su currículum en internet, y que llamó Personal Home Page Tools. Publicó el código en 1995, otros programadores se sumaron, y en 1998, con PHP 3, el nombre pasó a ser el acrónimo recursivo PHP: Hypertext Preprocessor. Desde entonces ha cambiado muchísimo: PHP 7 (2015) duplicó la velocidad, y PHP 8 añadió tipos mucho más estrictos, enums, propiedades de solo lectura y un compilador JIT. Si tu idea de PHP viene de código de hace quince años, este curso te va a sorprender.

Hoy lo mantienen un equipo de voluntarios y la PHP Foundation, y sale una versión nueva cada noviembre, con dos años de soporte normal y dos más solo de parches de seguridad. PHP 8.5, de noviembre de 2025, es la que usa este curso; cuánto dura cada versión y cómo mantenerla al día lo contamos en Versiones y parches.

Dónde se ejecuta: el servidor y la terminal

PHP es sobre todo un lenguaje de servidor: el código se ejecuta en la máquina que aloja la web, no en el navegador del visitante, que solo recibe el HTML que PHP genera.

El navegador pide saludo.php; el servidor web, Nginx o Apache, se lo pasa a PHP, que ejecuta el archivo, consulta si hace falta una base de datos y devuelve HTML al navegador. Cada petición empieza de ceroQué pasa cuando visitas una página hecha con PHPnavegadorpide /saludo.phpservidor webNginx o ApachePHPejecuta saludo.phprespuestaHTML de vueltabase de datosCada petición empieza de cero: PHP no recuerda nada de la visita anterior.

El navegador pide una página, el servidor web se la pasa a PHP, PHP la ejecuta y devuelve HTML. El visitante nunca ve el código PHP. Esquema: decodigo.com.

La última línea del esquema es la idea más importante para entender PHP en la web: cada visita ejecuta el archivo desde cero, y al terminar se olvida todo. Las variables no sobreviven de una página a la siguiente; para recordar cosas están las sesiones, las cookies y la base de datos, que veremos en la lección 7. A cambio, un fallo en una petición no afecta a las demás.

Pero PHP también se ejecuta en la terminal, como Python, y así empezaremos: es la forma más rápida de aprender, y la misma que usan las tareas programadas, los scripts de mantenimiento o herramientas como Composer.

Instalar PHP

Según tu sistema:

  • Linux: sudo apt install php-cli en Debian y Ubuntu, o sudo dnf install php-cli en Fedora. Ojo: cada distribución trae la versión de PHP de cuando salió, que puede no ser la 8.5. En Versiones y parches explicamos cómo tener una actual.
  • macOS: con Homebrew, brew install php.
  • Windows: descarga el ZIP de windows.php.net, descomprímelo y añade la carpeta al PATH. Otra opción cómoda es usar PHP dentro de WSL, como en Linux.
  • Con Docker, sin instalar nada: docker run --rm -it -v "$PWD":/app -w /app php:8.5-cli php hola.php. Es lo que hemos usado para comprobar este curso.

Para ver qué versión tienes:

php -v
salidaPHP 8.5.12 (cli) (built: Oct 21 2026 15:48:53) (NTS)
Copyright (c) The PHP Group
Built by https://github.com/docker-library/php
Zend Engine v4.5.12, Copyright (c) Zend Technologies
    with Zend OPcache v8.5.12, Copyright (c), by Zend Technologies

Para escribir código vale cualquier editor. Los más usados son Visual Studio Code con la extensión PHP Intelephense y PhpStorm, de JetBrains.

El primer programa

Crea un archivo hola.php:

<?php

echo "Hola, mundo\n";

Probar en el editor →

Y ejecútalo desde la terminal, en la misma carpeta:

php hola.php
salidaHola, mundo

Todo archivo PHP empieza con <?php, que le dice a PHP dónde empieza su código. echo escribe texto, y \n es un salto de línea. Hay una etiqueta de cierre, ?>, pero en archivos que solo tienen PHP se recomienda no ponerla: cualquier espacio o línea en blanco que quede detrás se enviaría al navegador, y eso provoca el famoso error headers already sent cuando luego intentas enviar una cookie.

Variables y texto

<?php

$nombre = "Ana";
$edad = 31;

echo "Hola, $nombre. El año que viene tendrás " . ($edad + 1) . " años.\n";
echo 'Con comillas simples: $nombre\n', "\n";
echo "Con llaves también vale: {$nombre} tiene {$edad} años.\n";

Probar en el editor →

salidaHola, Ana. El año que viene tendrás 32 años.
Con comillas simples: $nombre\n
Con llaves también vale: Ana tiene 31 años.

Las variables empiezan siempre por $, no se declaran antes y no llevan tipo: lo tiene el valor. Las comillas dobles sustituyen las variables por su valor y entienden \n; las simples dejan el texto tal cual, por eso la segunda línea escribe literalmente $nombre\n. Para unir textos se usa el punto (.), no el +. Y echo admite varios valores separados por comas.

Leer lo que escribe el usuario

<?php

echo "¿Cómo te llamas? ";
$respuesta = trim((string) fgets(STDIN));
echo "Encantado, $respuesta.\n";
salida¿Cómo te llamas? Encantado, Luis.

fgets(STDIN) lee una línea de la entrada estándar, con su salto de línea incluido; trim lo quita. Si no llega nada, fgets devuelve false, y (string) lo convierte en un texto vacío para que trim no se queje. En la salida no aparece «Luis» porque aquí la entrada vino de un archivo y no del teclado.

Tu primer error

Toda instrucción termina en punto y coma. Si se te olvida, PHP no ejecuta ni la primera línea:

<?php

echo "Hola, mundo\n"

Probar en el editor →

salida con error
Parse error: syntax error, unexpected end of file, expecting "," or ";" in /curso/pcoma.php on line 4

El mensaje dice el tipo de error, qué esperaba, el archivo y la línea. Aquí apunta a la línea 4, una después del fallo, porque PHP solo se da cuenta al llegar al final del archivo sin encontrar el punto y coma. Los mensajes de PHP están siempre en inglés. Puedes comprobar la sintaxis de un archivo sin ejecutarlo con php -l:

php -l hola.php
php -l pcoma.php
salidaNo syntax errors detected in hola.php
Parse error: syntax error, unexpected end of file, expecting "," or ";" in pcoma.php on line 4
Errors parsing pcoma.php

El error que te ahorra: una variable mal escrita

<?php

$nombre = "Ana";
echo "Hola, $nombe.\n";
echo "Fin del programa\n";

Probar en el editor →

salida
Warning: Undefined variable $nombe in /curso/ahorra_variable.php on line 4
Hola, .
Fin del programa

Escribimos $nombe en vez de $nombre. Antes de PHP 8 esto era un simple notice que muchos servidores ni mostraban; desde PHP 8 es un Warning. Aun así, el programa sigue y escribe «Hola, .»: PHP avisa, pero no se detiene. Por eso, mientras desarrollas, ten siempre los errores visibles (lo vemos justo abajo), y en la lección 6 usaremos PHPStan, que encuentra este fallo sin ejecutar el programa.

PHP en la web, con el servidor integrado

Para probar PHP en el navegador no hace falta instalar Apache ni Nginx: PHP trae un servidor de desarrollo. Crea una carpeta con este saludo.php:

<?php
$nombre = $_GET['nombre'] ?? 'mundo';
?>
<h1>Hola, <?= $nombre ?></h1>

Fíjate en que aquí sí hay una etiqueta de cierre ?>: después viene HTML normal, y <?= $nombre ?> es la forma corta de escribir un valor en medio del HTML. $_GET contiene los parámetros de la dirección, lo que va después del ?, y ?? da un valor por omisión si no llega ninguno. Arranca el servidor en esa carpeta:

php -S localhost:8000

Y abre http://localhost:8000/saludo.php?nombre=Ana en el navegador. Esto es lo que devuelve:

respuesta<h1>Hola, Ana</h1>

El servidor integrado es solo para desarrollar: atiende las peticiones de una en una y no está pensado para recibir tráfico real. En producción, PHP funciona con Nginx o Apache y PHP-FPM; lo veremos en la lección 8.

Ojo con la seguridad: escribir en la página lo que manda el visitante

Ese saludo.php tiene un fallo grave, y es el más común de la web. Mira lo que pasa si alguien pone código en el parámetro: saludo.php?nombre=<script>alert('hola')</script>

respuesta<h1>Hola, <script>alert('hola')</script></h1>

PHP mete el texto tal cual en la página, y el navegador ejecuta ese <script>. Un atacante no pondría un alert: enviaría a sus víctimas un enlace con un script que roba su sesión o rellena un formulario en su nombre. Se llama XSS (cross-site scripting). La solución es escapar todo lo que se escribe en el HTML con htmlspecialchars, que convierte <, >, & y las comillas en texto inofensivo:

<?php
$nombre = $_GET['nombre'] ?? 'mundo';
?>
<h1>Hola, <?= htmlspecialchars($nombre) ?></h1>
respuesta<h1>Hola, &lt;script&gt;alert(&#039;hola&#039;)&lt;/script&gt;</h1>

Ahora el navegador muestra el texto en vez de ejecutarlo. La regla, desde el primer día: todo lo que venga de fuera se escapa al escribirlo en la página. Esta demostración se hizo en local, contra el servidor de pruebas.

Más en Los fallos de seguridad típicos de PHP.

La configuración: php.ini

El comportamiento de PHP se ajusta en un archivo php.ini. Para saber cuál está usando:

php --ini
salidaConfiguration File (php.ini) Path: "/usr/local/etc/php"
Loaded Configuration File:         (none)
Scan for additional .ini files in: "/usr/local/etc/php/conf.d"
Additional .ini files parsed:      /usr/local/etc/php/conf.d/docker-php-ext-sodium.ini

En la imagen oficial de Docker no hay ningún php.ini cargado, solo dos plantillas en esa carpeta: php.ini-development y php.ini-production. En tu máquina suele haber uno ya instalado. La diferencia entre las dos plantillas que más importa es esta:

; Desarrollo: ver todos los errores
display_errors = On
error_reporting = E_ALL

; Producción: no enseñar los errores al visitante, guardarlos en un registro
display_errors = Off
log_errors = On

En tu ordenador quieres ver todos los avisos, como el de la variable mal escrita. En un servidor público, nunca: un mensaje de error puede revelar rutas, nombres de tablas o fragmentos de código a cualquiera. Ese es el primer punto de la lista de seguridad.

Lección 2. Tipos y variables

Los tipos de PHP

En PHP las variables no tienen tipo, pero los valores sí. var_dump enseña el valor junto con su tipo, y es la herramienta que más usarás para entender qué está pasando:

<?php

var_dump(42);
var_dump(3.14);
var_dump("hola");
var_dump(true);
var_dump(null);
var_dump([1, 2, 3]);

echo get_debug_type(42), " ", get_debug_type(3.14), " ", get_debug_type("hola"), "\n";

Probar en el editor →

salidaint(42)
float(3.14)
string(4) "hola"
bool(true)
NULL
array(3) {
  [0]=>
  int(1)
  [1]=>
  int(2)
  [2]=>
  int(3)
}
int float string

Los escalares son int, float, string y bool; null significa «sin valor», y los arrays tienen su lección, la 3. string(4) no cuenta letras sino bytes, y en la lección 4 veremos por qué eso importa con los acentos. Una misma variable puede cambiar de tipo:

<?php

$x = 5;
var_dump($x);
$x = "cinco";
var_dump($x);

Probar en el editor →

salidaint(5)
string(5) "cinco"

Tipos en las funciones, y strict_types

Lo que sí tiene tipo son los parámetros y el valor de retorno de las funciones, si los escribes. Las funciones las vemos a fondo en la lección 4; aquí basta saber que int $n pide un entero y : int promete devolver otro. La pregunta es qué hace PHP si le pasas otra cosa:

<?php

function doble(int $n): int
{
    return $n * 2;
}

var_dump(doble(5));
var_dump(doble("5"));
var_dump(doble(5.5));

Probar en el editor →

salidaint(10)
int(10)

Deprecated: Implicit conversion from float 5.5 to int loses precision in /curso/coercion.php on line 3
int(10)

Por omisión, PHP convierte lo que puede: el texto "5" se convierte en el entero 5, y 5.5 se queda en 5 perdiendo los decimales, con un aviso de que eso está obsoleto. Es el modo «coercitivo», heredado de cuando todo en la web era texto. Con una línea al principio del archivo se cambia al modo estricto:

Sin declare(strict_types=1), PHP convierte el texto 5 en el entero 5 y doble devuelve 10. Con strict_types=1, el mismo texto provoca un TypeError. La declaración afecta al archivo que hace la llamadaEl mismo doble("5"), con y sin strict_typesfunction doble(int $n): intsin declare"5" se convierte en 5int(10)strict_types=1"5" no es un intTypeErrorLa declaración vale solo para el archivo donde se hace la llamada.

Sin strict_types, PHP convierte el argumento al tipo pedido si puede; con strict_types, cualquier tipo distinto es un error. Esquema: decodigo.com.

El error que te ahorra: pasar un dato del tipo equivocado

<?php
declare(strict_types=1);

function doble(int $n): int
{
    return $n * 2;
}

var_dump(doble(5));
var_dump(doble("5"));

Probar en el editor →

salida con errorint(10)

Fatal error: Uncaught TypeError: doble(): Argument #1 ($n) must be of type int, string given, called in /curso/estricto.php on line 10 and defined in /curso/estricto.php:4
Stack trace:
#0 /curso/estricto.php(10): doble('5')
#1 {main}
  thrown in /curso/estricto.php on line 4

Con declare(strict_types=1), pasar un texto donde se pide un entero es un TypeError y el programa se detiene en esa línea, en vez de seguir con un valor convertido que quizá no era el que querías. Ponlo en todos tus archivos, como haremos en el resto del curso: tiene que ser la primera instrucción y solo afecta al archivo donde está. La única excepción tolerada es pasar un int donde se pide un float.

Los números

<?php
declare(strict_types=1);

var_dump(7 / 2);
var_dump(intdiv(7, 2));
var_dump(7 % 2);

echo 0.1 + 0.2, "\n";
var_dump(0.1 + 0.2);

var_dump(PHP_INT_MAX);
var_dump(PHP_INT_MAX + 1);
var_dump((int) 3.9);

Probar en el editor →

salidafloat(3.5)
int(3)
int(1)
0.3
float(0.30000000000000004)
int(9223372036854775807)
float(9.223372036854776E+18)
int(3)

Al contrario que en C o Java, 7 / 2 da 3.5: la división devuelve un float cuando no es exacta, y para la división entera está intdiv. El float es aproximado, como en casi todos los lenguajes: 0.1 + 0.2 no es exactamente 0.3, aunque echo lo redondee al mostrarlo y var_dump enseñe el valor real. Un int que se pasa de su máximo no da la vuelta: se convierte en float y pierde precisión. Y (int) corta los decimales, no redondea; para eso está round.

Para dinero, enteros. Como float es aproximado, los importes se guardan en céntimos con int (1999 en vez de 19.99) y solo se formatean al mostrarlos, con number_format(1999 / 100, 2). Si necesitas decimales exactos de verdad, existe la extensión bcmath, que no viene en todas las instalaciones.

Convertir texto en números, con cuidado

Lo que llega de un formulario, de un archivo o de la línea de órdenes es texto. Convertirlo con (int) parece fácil, pero nunca falla, y eso es un problema:

<?php
declare(strict_types=1);

var_dump((int) "42");
var_dump((int) "12 manzanas");
var_dump((int) "manzanas");

var_dump(is_numeric("12 manzanas"));
var_dump(filter_var("42", FILTER_VALIDATE_INT));
var_dump(filter_var("12 manzanas", FILTER_VALIDATE_INT));

Probar en el editor →

salidaint(42)
int(12)
int(0)
bool(false)
int(42)
bool(false)

(int) toma los dígitos del principio y, si no hay, devuelve 0 sin avisar: «manzanas» se convierte en 0, que casi nunca es lo que quieres. Para validar lo que escribe una persona, usa filter_var con FILTER_VALIDATE_INT: devuelve el número o false si el texto no es un entero completo.

== frente a ===

PHP tiene dos comparaciones de igualdad. == convierte los dos lados a un tipo común antes de comparar; === exige que sean del mismo tipo y del mismo valor:

<?php
declare(strict_types=1);

$casos = [
    ['1', '01'],
    ['10', '1e1'],
    [100, '1e2'],
    [0, 'a'],
    [null, false],
    ['abc', 'ABC'],
];

foreach ($casos as [$a, $b]) {
    printf("%-14s ==  %-5s ===  %s\n",
        var_export($a, true) . ' y ' . var_export($b, true),
        var_export($a == $b, true),
        var_export($a === $b, true));
}

Probar en el editor →

salida'1' y '01'     ==  true  ===  false
'10' y '1e1'   ==  true  ===  false
100 y '1e2'    ==  true  ===  false
0 y 'a'        ==  false ===  false
NULL y false   ==  true  ===  false
'abc' y 'ABC'  ==  false ===  false

Dos textos que parecen números se comparan como números: '10' y '1e1' (diez en notación científica) son «iguales» para ==. En PHP 7, además, 0 == 'a' daba true; PHP 8 lo corrigió, pero quedan bastantes casos raros, y los repasamos en Rarezas y fallos de diseño. La regla práctica: usa siempre === y !==, salvo que tengas un motivo para querer la conversión.

Ojo con la seguridad: comparar contraseñas o tokens con ==

<?php
declare(strict_types=1);

$a = md5('240610708');
$b = md5('QNKCDZO');
echo $a, "\n", $b, "\n";

var_dump($a == $b);
var_dump($a === $b);
var_dump(hash_equals($a, $b));

Probar en el editor →

salida0e462097431906509019562988736854
0e830400451993494058024219903391
bool(true)
bool(false)
bool(false)

Los resúmenes MD5 de esos dos textos distintos empiezan por 0e seguido solo de cifras. Para ==, los dos son números en notación científica, cero por diez elevado a algo, y valen lo mismo: cero. Si un sistema guardara contraseñas en MD5 y las comparara con ==, la contraseña QNKCDZO abriría la cuenta cuya contraseña es 240610708. Los fallos reales con este patrón tienen nombre: magic hashes. Compara siempre con === y, para secretos, con hash_equals, que además tarda lo mismo acierte o falle y no da pistas a quien mide el tiempo. Y no guardes contraseñas con MD5: en la lección 7 usaremos password_hash.

Más en Los fallos de seguridad típicos de PHP.

switch frente a match

Por la misma razón, switch compara con ==. Desde PHP 8 existe match, que compara con ===, devuelve un valor y falla si ningún caso coincide en lugar de seguir en silencio:

<?php
declare(strict_types=1);

$codigo = '1e1';

switch ($codigo) {
    case '10':
        echo "switch: es el diez\n";
        break;
    default:
        echo "switch: otro\n";
}

echo match ($codigo) {
    '10' => "match: es el diez",
    default => "match: otro",
}, "\n";

Probar en el editor →

salidaswitch: es el diez
match: otro

null, ?? y los tipos que aceptan null

Un valor que puede faltar se marca en los tipos con ? delante: ?string es «un texto o null». Para trabajar con valores que pueden faltar hay tres operadores:

<?php
declare(strict_types=1);

function saludo(?string $nombre): string
{
    return "Hola, " . ($nombre ?? "desconocido");
}

echo saludo("Ana"), "\n";
echo saludo(null), "\n";

$config = ['idioma' => 'es'];
echo $config['tema'] ?? 'claro', "\n";

$config['tema'] ??= 'oscuro';
echo $config['tema'], "\n";

$fecha = null;
var_dump($fecha?->format('Y'));

Probar en el editor →

salidaHola, Ana
Hola, desconocido
claro
oscuro
NULL

?? da un valor por omisión si lo de la izquierda es null o no existe, y no avisa si la clave del array no está; ??= asigna solo si hace falta, y ?-> llama a un método solo si el objeto no es null (si lo es, el resultado es null en vez de un error).

Constantes

<?php
declare(strict_types=1);

const IVA = 0.16;
const NOMBRE_TIENDA = "La Esquina";

$precio = 1000;
echo NOMBRE_TIENDA, ": ", $precio * (1 + IVA), "\n";
echo PHP_VERSION, " en ", PHP_OS_FAMILY, "\n";

Probar en el editor →

salidaLa Esquina: 1160
8.5.12 en Linux

Las constantes se escriben sin $ y, por costumbre, en mayúsculas. PHP trae muchas predefinidas, como PHP_VERSION, PHP_INT_MAX o PHP_EOL (el salto de línea del sistema).

Resumen de las lecciones 1 y 2

ParaEscribeRecuerda
Ejecutar un archivophp hola.phpphp -l revisa la sintaxis sin ejecutar
Probar en el navegadorphp -S localhost:8000solo para desarrollar
Escribir en la página<?= htmlspecialchars($x) ?>nunca un dato de fuera sin escapar
Tipos estrictosdeclare(strict_types=1);primera línea de cada archivo
Comparar=== y !==== convierte los tipos
Texto a enterofilter_var($s, FILTER_VALIDATE_INT)(int) nunca falla, y por eso engaña
Un valor por omisión$x ?? 'nada'?string en los tipos si puede faltar
Dinerocéntimos en intfloat es aproximado

Retos

Reto 1. Hay que repartir 7 entre 2 y escribir el resultado. En C o en Java, 7 / 2 da 3 y hay que convertir uno de los dos a decimal. ¿Y en PHP? Escribe el programa.

Ver la solución
<?php
declare(strict_types=1);

$total = 7;
$partes = 2;
echo $total / $partes, "\n";

Probar en el editor →

salida3.5

En PHP no hace falta convertir nada: / ya devuelve un float si la división no es exacta. Si quieres la división entera, usa intdiv.

Reto 2. Pide al usuario su año de nacimiento y escribe cuántos años cumple en 2026. Si no escribe un número entero, que el programa lo diga.

Ver la solución
<?php
declare(strict_types=1);

echo "¿En qué año naciste? ";
$texto = trim((string) fgets(STDIN));
$anio = filter_var($texto, FILTER_VALIDATE_INT);

if ($anio === false) {
    echo "Eso no parece un año.\n";
} else {
    echo "En 2026 cumples ", 2026 - $anio, " años.\n";
}
salida¿En qué año naciste? En 2026 cumples 31 años.

Fíjate en el === false: si alguien naciera en el año 0, filter_var devolvería 0, y if (!$anio) lo tomaría por un error.

Reto 3. ¿Qué escribe este programa? Piénsalo antes de mirar. var_dump('abc' == 0); var_dump('1' == '01'); var_dump('1' === '01');

Ver la solución
<?php

var_dump('abc' == 0);
var_dump('1' == '01');
var_dump('1' === '01');

Probar en el editor →

salidabool(false)
bool(true)
bool(false)

Desde PHP 8, un texto que no es numérico ya no es igual a 0. Pero dos textos numéricos se siguen comparando como números con ==, así que '1' y '01' son «iguales». Con ===, no.

Cómo está hecho este curso

Cada fragmento de código se ejecutó con PHP 8.5.12 (cli) (built: Oct 21 2026 15:48:53) (NTS) en un contenedor limpio de la imagen oficial php:8.5-cli, con un usuario normal y sin acceso a la red. Las salidas y los mensajes de error están copiados de esa ejecución; por eso las rutas que aparecen en ellos empiezan por /curso/, la carpeta donde se ejecutaron. Las demostraciones de ataques, como la del XSS, se hicieron solo contra el servidor de pruebas, dentro de ese contenedor.

¿Buscas una receta concreta en vez de una lección? En Ejemplos de PHP hay tareas resueltas: JSON, CSV, fechas, SQLite con PDO, un inicio de sesión seguro, subir archivos… Y si administras un servidor con PHP, empieza por Versiones y parches.