Estructura de Proyecto
Una buena estructura de proyecto facilita el mantenimiento, la colaboración y la escalabilidad. Veamos cómo organizar un proyecto PHP profesional combinando todo lo aprendido sobre namespaces.
Estructura estándar de un proyecto PHP
Esta es una estructura típica usada en la industria:
mi-proyecto/
├── composer.json
├── composer.lock
├── .gitignore
├── README.md
│
├── config/ # Archivos de configuración
│ └── app.php
│
├── public/ # Punto de entrada web (document root)
│ ├── index.php
│ ├── css/
│ ├── js/
│ └── images/
│
├── src/ # Código fuente de la aplicación (App\)
│ ├── Controllers/
│ ├── Models/
│ ├── Services/
│ ├── Repositories/
│ └── Exceptions/
│
├── tests/ # Tests (Tests\)
│ ├── Unit/
│ └── Integration/
│
├── var/ # Archivos generados (cache, logs)
│ ├── cache/
│ └── logs/
│
└── vendor/ # Dependencias (ignorar en git)
Propósito de cada carpeta
public/
Es el único directorio accesible desde el
navegador. El servidor web debe apuntar aquí
como document root. Contiene
index.php (el punto de entrada) y
archivos estáticos (CSS, JS, imágenes).
src/
Todo el código PHP de tu aplicación. Aquí van
las clases organizadas por responsabilidad. El
namespace App\ apunta aquí.
config/
Archivos de configuración de la aplicación. Retornan arrays con valores de configuración.
tests/
Tests automatizados. El namespace
Tests\ apunta aquí.
var/
Archivos generados en tiempo de ejecución: cache, logs, archivos temporales. Debe tener permisos de escritura.
Configuración de composer.json
{
"name": "tu-usuario/mi-proyecto",
"description": "Mi aplicación PHP",
"type": "project",
"require": {
"php": ">=8.1"
},
"require-dev": {
"phpunit/phpunit": "^10.0"
},
"autoload": {
"psr-4": {
"App\\": "src/"
}
},
"autoload-dev": {
"psr-4": {
"Tests\\": "tests/"
}
},
"scripts": {
"test": "phpunit"
}
}
El punto de entrada: public/index.php
Este archivo recibe todas las peticiones web e inicializa la aplicación:
<?php
// public/index.php
declare(strict_types=1);
// Definir la raíz del proyecto
define('ROOT_PATH', dirname(__DIR__));
// Cargar el autoloader de Composer
require ROOT_PATH . '/vendor/autoload.php';
// Cargar configuración
$config = require ROOT_PATH . '/config/app.php';
// Iniciar la aplicación
use App\Application;
$app = new Application($config);
$app->run();
Archivo de configuración
<?php
// config/app.php
declare(strict_types=1);
return [
'name' => 'Mi Aplicación',
'debug' => true,
'timezone' => 'Europe/Madrid',
'paths' => [
'root' => dirname(__DIR__),
'logs' => dirname(__DIR__) . '/var/logs',
'cache' => dirname(__DIR__) . '/var/cache',
],
];
Organización del código en src/
Models: Entidades del dominio
<?php
// src/Models/Usuario.php
namespace App\Models;
declare(strict_types=1);
class Usuario
{
public function __construct(
public readonly int $id,
public readonly string $email,
public readonly string $nombre,
public readonly \DateTimeImmutable $creadoEn
) {}
}
Services: Lógica de negocio
<?php
// src/Services/UsuarioService.php
namespace App\Services;
declare(strict_types=1);
use App\Models\Usuario;
use App\Repositories\UsuarioRepository;
use App\Exceptions\UsuarioNoEncontradoException;
use DateTimeImmutable;
class UsuarioService
{
public function __construct(
private readonly UsuarioRepository $repository
) {}
public function registrar(string $email, string $nombre): Usuario
{
$usuario = new Usuario(
id: 0, // El repositorio asignará el ID real
email: $email,
nombre: $nombre,
creadoEn: new DateTimeImmutable()
);
return $this->repository->guardar($usuario);
}
public function buscarPorId(int $id): Usuario
{
$usuario = $this->repository->buscarPorId($id);
if ($usuario === null) {
throw new UsuarioNoEncontradoException("Usuario $id no encontrado");
}
return $usuario;
}
}
Repositories: Acceso a datos
<?php
// src/Repositories/UsuarioRepository.php
namespace App\Repositories;
declare(strict_types=1);
use App\Models\Usuario;
interface UsuarioRepository
{
public function buscarPorId(int $id): ?Usuario;
public function buscarPorEmail(string $email): ?Usuario;
public function guardar(Usuario $usuario): Usuario;
public function eliminar(int $id): bool;
}
<?php
// src/Repositories/UsuarioRepositoryEnMemoria.php
namespace App\Repositories;
declare(strict_types=1);
use App\Models\Usuario;
use DateTimeImmutable;
class UsuarioRepositoryEnMemoria implements UsuarioRepository
{
/** @var Usuario[] */
private array $usuarios = [];
private int $siguienteId = 1;
public function buscarPorId(int $id): ?Usuario
{
return $this->usuarios[$id] ?? null;
}
public function buscarPorEmail(string $email): ?Usuario
{
foreach ($this->usuarios as $usuario) {
if ($usuario->email === $email) {
return $usuario;
}
}
return null;
}
public function guardar(Usuario $usuario): Usuario
{
$nuevoUsuario = new Usuario(
id: $this->siguienteId,
email: $usuario->email,
nombre: $usuario->nombre,
creadoEn: $usuario->creadoEn
);
$this->usuarios[$this->siguienteId] = $nuevoUsuario;
$this->siguienteId++;
return $nuevoUsuario;
}
public function eliminar(int $id): bool
{
if (isset($this->usuarios[$id])) {
unset($this->usuarios[$id]);
return true;
}
return false;
}
}
Exceptions: Excepciones personalizadas
<?php
// src/Exceptions/UsuarioNoEncontradoException.php
namespace App\Exceptions;
declare(strict_types=1);
use RuntimeException;
class UsuarioNoEncontradoException extends RuntimeException
{
}
Controllers: Manejar peticiones
<?php
// src/Controllers/UsuarioController.php
namespace App\Controllers;
declare(strict_types=1);
use App\Services\UsuarioService;
use App\Exceptions\UsuarioNoEncontradoException;
class UsuarioController
{
public function __construct(
private readonly UsuarioService $usuarioService
) {}
public function mostrar(int $id): array
{
try {
$usuario = $this->usuarioService->buscarPorId($id);
return [
'success' => true,
'data' => [
'id' => $usuario->id,
'email' => $usuario->email,
'nombre' => $usuario->nombre,
],
];
} catch (UsuarioNoEncontradoException $e) {
return [
'success' => false,
'error' => $e->getMessage(),
];
}
}
}
La clase Application
<?php
// src/Application.php
namespace App;
declare(strict_types=1);
use App\Controllers\UsuarioController;
use App\Services\UsuarioService;
use App\Repositories\UsuarioRepositoryEnMemoria;
class Application
{
public function __construct(
private readonly array $config
) {
date_default_timezone_set($config['timezone'] ?? 'UTC');
}
public function run(): void
{
// Crear dependencias (en un proyecto real usarías un contenedor DI)
$repository = new UsuarioRepositoryEnMemoria();
$service = new UsuarioService($repository);
$controller = new UsuarioController($service);
// Simular una petición
$resultado = $service->registrar('ana@example.com', 'Ana García');
echo "Usuario registrado: {$resultado->nombre} (ID: {$resultado->id})\n";
$respuesta = $controller->mostrar($resultado->id);
echo json_encode($respuesta, JSON_PRETTY_PRINT) . "\n";
}
}
Archivo .gitignore
# Dependencias
/vendor/
# Archivos generados
/var/cache/*
/var/logs/*
!var/cache/.gitkeep
!var/logs/.gitkeep
# IDE
.idea/
.vscode/
*.swp
# Sistema
.DS_Store
Thumbs.db
# Configuración local
.env
config/local.php
Resumen de la organización
mi-proyecto/
├── composer.json
├── .gitignore
│
├── config/
│ └── app.php
│
├── public/
│ └── index.php # Punto de entrada
│
├── src/
│ ├── Application.php # App\Application
│ ├── Controllers/
│ │ └── UsuarioController.php
│ ├── Exceptions/
│ │ └── UsuarioNoEncontradoException.php
│ ├── Models/
│ │ └── Usuario.php
│ ├── Repositories/
│ │ ├── UsuarioRepository.php
│ │ └── UsuarioRepositoryEnMemoria.php
│ └── Services/
│ └── UsuarioService.php
│
├── tests/
│ └── Unit/
│ └── Services/
│ └── UsuarioServiceTest.php
│
├── var/
│ ├── cache/
│ │ └── .gitkeep
│ └── logs/
│ └── .gitkeep
│
└── vendor/
Ejercicios
Ejercicio 1: Crear estructura de proyecto
Crea un proyecto nuevo con la estructura
mostrada. Implementa un modelo
Producto con id, nombre y
precio, un
ProductoRepository
(interfaz e implementación en memoria),
y un ProductoService con
métodos para crear y listar productos.
Verifica que todo funciona desde
public/index.php.
Ver solución
<?php
// src/Models/Producto.php
namespace App\Models;
declare(strict_types=1);
class Producto
{
public function __construct(
public readonly int $id,
public readonly string $nombre,
public readonly float $precio
) {}
}
// src/Repositories/ProductoRepositoryInterface.php
namespace App\Repositories;
declare(strict_types=1);
use App\Models\Producto;
interface ProductoRepositoryInterface
{
public function guardar(Producto $producto): void;
public function buscar(int $id): ?Producto;
public function todos(): array;
}
// src/Repositories/ProductoRepositoryMemoria.php
namespace App\Repositories;
declare(strict_types=1);
use App\Models\Producto;
class ProductoRepositoryMemoria implements ProductoRepositoryInterface
{
private array $productos = [];
public function guardar(Producto $producto): void
{
$this->productos[$producto->id] = $producto;
}
public function buscar(int $id): ?Producto
{
return $this->productos[$id] ?? null;
}
public function todos(): array
{
return array_values($this->productos);
}
}
// src/Services/ProductoService.php
namespace App\Services;
declare(strict_types=1);
use App\Models\Producto;
use App\Repositories\ProductoRepositoryInterface;
class ProductoService
{
public function __construct(
private readonly ProductoRepositoryInterface $repo
) {}
public function crear(int $id, string $nombre, float $precio): Producto
{
$producto = new Producto($id, $nombre, $precio);
$this->repo->guardar($producto);
return $producto;
}
public function listar(): array
{
return $this->repo->todos();
}
}
Ejercicio 2: Añadir excepciones
Extiende el proyecto anterior añadiendo
ProductoNoEncontradoException
y PrecioInvalidoException.
El servicio debe lanzar
PrecioInvalidoException si
el precio es negativo, y
ProductoNoEncontradoException
si se busca un ID inexistente.
Ver solución
<?php
// src/Exceptions/ProductoNoEncontradoException.php
namespace App\Exceptions;
declare(strict_types=1);
use Exception;
class ProductoNoEncontradoException extends Exception
{
public function __construct(int $id)
{
parent::__construct("Producto con ID $id no encontrado");
}
}
// src/Exceptions/PrecioInvalidoException.php
namespace App\Exceptions;
declare(strict_types=1);
use Exception;
class PrecioInvalidoException extends Exception
{
public function __construct(float $precio)
{
parent::__construct("El precio $precio no es válido");
}
}
// src/Services/ProductoService.php (actualizado)
namespace App\Services;
declare(strict_types=1);
use App\Models\Producto;
use App\Repositories\ProductoRepositoryInterface;
use App\Exceptions\{ProductoNoEncontradoException, PrecioInvalidoException};
class ProductoService
{
public function __construct(
private readonly ProductoRepositoryInterface $repo
) {}
public function crear(int $id, string $nombre, float $precio): Producto
{
if ($precio < 0) {
throw new PrecioInvalidoException($precio);
}
$producto = new Producto($id, $nombre, $precio);
$this->repo->guardar($producto);
return $producto;
}
public function buscar(int $id): Producto
{
$producto = $this->repo->buscar($id);
if ($producto === null) {
throw new ProductoNoEncontradoException($id);
}
return $producto;
}
}
Ejercicio 3: Controlador completo
Crea un
ProductoController con
métodos listar(),
mostrar(int $id) y
crear(array $datos). Cada
método debe retornar un array con
success y
data o error.
Maneja las excepciones apropiadamente.
Ver solución
<?php
// src/Controllers/ProductoController.php
namespace App\Controllers;
declare(strict_types=1);
use App\Services\ProductoService;
use App\Exceptions\ProductoNoEncontradoException;
use App\Exceptions\PrecioInvalidoException;
class ProductoController
{
public function __construct(
private readonly ProductoService $service
) {}
public function listar(): array
{
return [
'success' => true,
'data' => $this->service->listar(),
];
}
public function mostrar(int $id): array
{
try {
$producto = $this->service->buscar($id);
return [
'success' => true,
'data' => $producto,
];
} catch (ProductoNoEncontradoException $e) {
return [
'success' => false,
'error' => $e->getMessage(),
];
}
}
public function crear(array $datos): array
{
try {
$producto = $this->service->crear(
$datos['id'],
$datos['nombre'],
$datos['precio']
);
return [
'success' => true,
'data' => $producto,
];
} catch (PrecioInvalidoException $e) {
return [
'success' => false,
'error' => $e->getMessage(),
];
}
}
}
¿Has encontrado un error o tienes sugerencias para esta lección?
Enviar feedback¿Te está gustando el curso?
Tenemos cursos premium con proyectos reales y soporte.
Descubrir cursos premium