Lección 57 de 75 12 min de lectura

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:

Estructura
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

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
<?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
<?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
<?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
<?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
<?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
<?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
<?php

// src/Exceptions/UsuarioNoEncontradoException.php
namespace App\Exceptions;

declare(strict_types=1);

use RuntimeException;

class UsuarioNoEncontradoException extends RuntimeException
{
}

Controllers: Manejar peticiones

PHP
<?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
<?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

.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

Estructura final
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(),
            ];
        }
    }
}

¿Te está gustando el curso?

Tenemos cursos premium con proyectos reales y soporte.

Descubrir cursos premium