Building a Website with the Play Framework

The Play Framework is a modern, reactive web framework for Scala and Java that makes it easy to build scalable web applications. This lesson introduces the Play Framework, showing you how to create web applications with MVC architecture, RESTful APIs, and database integration.

Introduction to Play Framework

Play is a high-productivity web application framework that follows the Model-View-Controller (MVC) architectural pattern. It's designed to be developer-friendly with features like hot reloading, type-safe routes, and excellent tooling support.

Key Features of Play Framework

// Key Play Framework features:
// 1. Hot reloading during development
// 2. Type-safe routing and reverse routing
// 3. Reactive and non-blocking I/O
// 4. Built-in JSON support
// 5. WebSocket and Server-Sent Events support
// 6. Integration with Akka actors
// 7. Database evolution and migration support
// 8. Comprehensive testing support

Setting Up a Play Project

Creating a New Play Application

# Using Scala CLI to create a Play project
scala-cli --power new playframework/play-scala-seed.g8

# Or using sbt
sbt new playframework/play-scala-seed.g8

# Project structure:
# my-play-app/
# ├── app/
# │   ├── controllers/
# │   ├── models/
# │   ├── views/
# │   └── Module.scala
# ├── conf/
# │   ├── application.conf
# │   ├── routes
# │   └── evolutions/
# ├── public/
# │   ├── stylesheets/
# │   ├── javascripts/
# │   └── images/
# ├── test/
# ├── build.sbt
# └── project/

build.sbt Configuration

// build.sbt
ThisBuild / scalaVersion := "2.13.11"
ThisBuild / version := "1.0-SNAPSHOT"

lazy val root = (project in file("."))
  .enablePlugins(PlayScala)
  .settings(
    name := "my-play-app",
    libraryDependencies ++= Seq(
      guice,
      "org.scalatestplus.play" %% "scalatestplus-play" % "5.1.0" % Test,
      "com.typesafe.play" %% "play-slick" % "5.0.2",
      "com.typesafe.play" %% "play-slick-evolutions" % "5.0.2",
      "com.h2database" % "h2" % "2.1.214",
      "org.postgresql" % "postgresql" % "42.6.0",
      "com.typesafe.play" %% "play-json" % "2.9.4",
      "org.scalatestplus" %% "mockito-4-6" % "3.2.15.0" % Test
    ),
    Assets / pipelineStages := Seq(digest)
  )

// project/plugins.sbt
addSbtPlugin("com.typesafe.play" % "sbt-plugin" % "2.8.19")
addSbtPlugin("org.foundweekends.giter8" % "sbt-giter8-scaffold" % "0.13.1")

MVC Architecture in Play

Controllers: Handling HTTP Requests

// app/controllers/HomeController.scala
package controllers

import javax.inject._
import play.api.mvc._
import play.api.libs.json._
import scala.concurrent.{ExecutionContext, Future}

@Singleton
class HomeController @Inject()(
  val controllerComponents: ControllerComponents
)(implicit ec: ExecutionContext) extends BaseController {

  // Simple action returning HTML
  def index() = Action { implicit request: Request[AnyContent] =>
    Ok(views.html.index("Welcome to Play Framework!"))
  }

  // Action with parameters
  def hello(name: String) = Action { implicit request =>
    Ok(views.html.hello(name))
  }

  // JSON API endpoint
  def api() = Action { implicit request =>
    val data = Json.obj(
      "message" -> "Hello from Play API",
      "timestamp" -> System.currentTimeMillis(),
      "version" -> "1.0"
    )
    Ok(data)
  }

  // Async action with Future
  def asyncData() = Action.async { implicit request =>
    Future {
      val data = Json.obj(
        "data" -> "Async response",
        "processed" -> true
      )
      Ok(data)
    }
  }
}

// app/controllers/UserController.scala
package controllers

import javax.inject._
import play.api.mvc._
import play.api.libs.json._
import models.{User, UserRepository}
import scala.concurrent.{ExecutionContext, Future}

@Singleton
class UserController @Inject()(
  userRepository: UserRepository,
  val controllerComponents: ControllerComponents
)(implicit ec: ExecutionContext) extends BaseController {

  // GET /users
  def list() = Action.async { implicit request =>
    userRepository.list().map { users =>
      Ok(Json.toJson(users))
    }
  }

  // GET /users/:id
  def get(id: Long) = Action.async { implicit request =>
    userRepository.get(id).map {
      case Some(user) => Ok(Json.toJson(user))
      case None => NotFound(Json.obj("error" -> "User not found"))
    }
  }

  // POST /users
  def create() = Action.async(parse.json) { implicit request =>
    request.body.validate[User] match {
      case JsSuccess(user, _) =>
        userRepository.create(user).map { createdUser =>
          Created(Json.toJson(createdUser))
        }
      case JsError(errors) =>
        Future.successful(BadRequest(Json.obj("errors" -> errors)))
    }
  }

  // PUT /users/:id
  def update(id: Long) = Action.async(parse.json) { implicit request =>
    request.body.validate[User] match {
      case JsSuccess(user, _) =>
        userRepository.update(id, user).map {
          case Some(updatedUser) => Ok(Json.toJson(updatedUser))
          case None => NotFound(Json.obj("error" -> "User not found"))
        }
      case JsError(errors) =>
        Future.successful(BadRequest(Json.obj("errors" -> errors)))
    }
  }

  // DELETE /users/:id
  def delete(id: Long) = Action.async { implicit request =>
    userRepository.delete(id).map { deleted =>
      if (deleted) Ok(Json.obj("message" -> "User deleted"))
      else NotFound(Json.obj("error" -> "User not found"))
    }
  }
}

Models: Data Layer

// app/models/User.scala
package models

import play.api.libs.json._

case class User(
  id: Option[Long] = None,
  name: String,
  email: String,
  age: Int,
  createdAt: Option[java.time.LocalDateTime] = None
)

object User {
  // JSON serialization
  implicit val userFormat: Format[User] = Json.format[User]

  // Custom validation
  def validate(user: User): Either[String, User] = {
    if (user.name.trim.isEmpty) {
      Left("Name cannot be empty")
    } else if (!user.email.contains("@")) {
      Left("Invalid email format")
    } else if (user.age < 0 || user.age > 150) {
      Left("Age must be between 0 and 150")
    } else {
      Right(user)
    }
  }
}

// app/models/UserRepository.scala
package models

import javax.inject.{Inject, Singleton}
import play.api.db.slick.DatabaseConfigProvider
import slick.jdbc.JdbcProfile
import scala.concurrent.{ExecutionContext, Future}
import java.time.LocalDateTime

@Singleton
class UserRepository @Inject()(
  dbConfigProvider: DatabaseConfigProvider
)(implicit ec: ExecutionContext) {

  private val dbConfig = dbConfigProvider.get[JdbcProfile]

  import dbConfig._
  import profile.api._

  // Table definition
  class UserTable(tag: Tag) extends Table[User](tag, "users") {
    def id = column[Long]("id", O.PrimaryKey, O.AutoInc)
    def name = column[String]("name")
    def email = column[String]("email")
    def age = column[Int]("age")
    def createdAt = column[LocalDateTime]("created_at")

    def * = (id.?, name, email, age, createdAt.?) <> ((User.apply _).tupled, User.unapply)
  }

  private val users = TableQuery[UserTable]

  // Repository methods
  def list(): Future[Seq[User]] = db.run {
    users.result
  }

  def get(id: Long): Future[Option[User]] = db.run {
    users.filter(_.id === id).result.headOption
  }

  def create(user: User): Future[User] = db.run {
    val userWithTimestamp = user.copy(createdAt = Some(LocalDateTime.now()))
    (users.map(u => (u.name, u.email, u.age, u.createdAt))
      returning users.map(_.id)
      into ((userData, id) => User(Some(id), userData._1, userData._2, userData._3, userData._4))
    ) += (userWithTimestamp.name, userWithTimestamp.email, userWithTimestamp.age, userWithTimestamp.createdAt.get)
  }

  def update(id: Long, user: User): Future[Option[User]] = db.run {
    val updateQuery = users.filter(_.id === id)
      .map(u => (u.name, u.email, u.age))
      .update((user.name, user.email, user.age))

    updateQuery.flatMap { rowsAffected =>
      if (rowsAffected > 0) {
        users.filter(_.id === id).result.headOption
      } else {
        DBIO.successful(None)
      }
    }.transactionally
  }

  def delete(id: Long): Future[Boolean] = db.run {
    users.filter(_.id === id).delete.map(_ > 0)
  }

  def findByEmail(email: String): Future[Option[User]] = db.run {
    users.filter(_.email === email).result.headOption
  }
}

Views: Twirl Templates

@* app/views/index.scala.html *@
@(title: String)

@main(title) {
    <div class="container">
        <div class="jumbotron">
            <h1 class="display-4">@title</h1>
            <p class="lead">Welcome to your Play Framework application!</p>
            <hr class="my-4">
            <p>Start building amazing web applications with Scala and Play.</p>
            <a class="btn btn-primary btn-lg" href="@routes.UserController.list()" role="button">
                View Users
            </a>
        </div>

        <div class="row">
            <div class="col-md-4">
                <h3>Reactive</h3>
                <p>Built on Akka, Play provides predictable and minimal resource consumption for highly-scalable applications.</p>
            </div>
            <div class="col-md-4">
                <h3>Type Safe</h3>
                <p>Type safety is built into Play's DNA. From routes to templates, everything is type-checked.</p>
            </div>
            <div class="col-md-4">
                <h3>Developer Friendly</h3>
                <p>Hot reloading, detailed error pages, and excellent tooling support make development a pleasure.</p>
            </div>
        </div>
    </div>
}

@* app/views/hello.scala.html *@
@(name: String)

@main(s"Hello $name") {
    <div class="container">
        <h1>Hello @name!</h1>
        <p>Welcome to the Play Framework application.</p>
        <a href="@routes.HomeController.index()" class="btn btn-secondary">Back to Home</a>
    </div>
}

@* app/views/userList.scala.html *@
@(users: Seq[User])

@main("Users") {
    <div class="container">
        <h1>Users</h1>

        <div class="mb-3">
            <button class="btn btn-primary" data-bs-toggle="modal" data-bs-target="#createUserModal">
                Create New User
            </button>
        </div>

        <table class="table table-striped">
            <thead>
                <tr>
                    <th>ID</th>
                    <th>Name</th>
                    <th>Email</th>
                    <th>Age</th>
                    <th>Created At</th>
                    <th>Actions</th>
                </tr>
            </thead>
            <tbody>
                @for(user <- users) {
                    <tr>
                        <td>@user.id.getOrElse("")</td>
                        <td>@user.name</td>
                        <td>@user.email</td>
                        <td>@user.age</td>
                        <td>@user.createdAt.map(_.format(java.time.format.DateTimeFormatter.ISO_LOCAL_DATE_TIME)).getOrElse("")</td>
                        <td>
                            <a href="@routes.UserController.get(user.id.getOrElse(0L))" class="btn btn-sm btn-outline-primary">View</a>
                            <button class="btn btn-sm btn-outline-danger" onclick="deleteUser(@user.id.getOrElse(0L))">Delete</button>
                        </td>
                    </tr>
                }
            </tbody>
        </table>

        @if(users.isEmpty) {
            <div class="alert alert-info">
                No users found. <a href="#" data-bs-toggle="modal" data-bs-target="#createUserModal">Create the first user</a>.
            </div>
        }
    </div>

    <!-- Create User Modal -->
    <div class="modal fade" id="createUserModal" tabindex="-1">
        <div class="modal-dialog">
            <div class="modal-content">
                <div class="modal-header">
                    <h5 class="modal-title">Create New User</h5>
                    <button type="button" class="btn-close" data-bs-dismiss="modal"></button>
                </div>
                <div class="modal-body">
                    <form id="createUserForm">
                        <div class="mb-3">
                            <label for="name" class="form-label">Name</label>
                            <input type="text" class="form-control" id="name" required>
                        </div>
                        <div class="mb-3">
                            <label for="email" class="form-label">Email</label>
                            <input type="email" class="form-control" id="email" required>
                        </div>
                        <div class="mb-3">
                            <label for="age" class="form-label">Age</label>
                            <input type="number" class="form-control" id="age" min="0" max="150" required>
                        </div>
                    </form>
                </div>
                <div class="modal-footer">
                    <button type="button" class="btn btn-secondary" data-bs-dismiss="modal">Cancel</button>
                    <button type="button" class="btn btn-primary" onclick="createUser()">Create User</button>
                </div>
            </div>
        </div>
    </div>

    <script>
        function createUser() {
            const form = document.getElementById('createUserForm');
            const formData = new FormData(form);
            const userData = {
                name: document.getElementById('name').value,
                email: document.getElementById('email').value,
                age: parseInt(document.getElementById('age').value)
            };

            fetch('@routes.UserController.create()', {
                method: 'POST',
                headers: {
                    'Content-Type': 'application/json',
                },
                body: JSON.stringify(userData)
            })
            .then(response => response.json())
            .then(data => {
                if (data.id) {
                    location.reload();
                } else {
                    alert('Error creating user: ' + JSON.stringify(data.errors || data.error));
                }
            })
            .catch(error => {
                alert('Error: ' + error.message);
            });
        }

        function deleteUser(id) {
            if (confirm('Are you sure you want to delete this user?')) {
                fetch(`@routes.UserController.delete(0L)`.replace('0', id), {
                    method: 'DELETE'
                })
                .then(response => response.json())
                .then(data => {
                    if (data.message) {
                        location.reload();
                    } else {
                        alert('Error deleting user: ' + data.error);
                    }
                })
                .catch(error => {
                    alert('Error: ' + error.message);
                });
            }
        }
    </script>
}

@* app/views/main.scala.html *@
@(title: String)(content: Html)

<!DOCTYPE html>
<html lang="en">
    <head>
        <meta charset="utf-8">
        <meta name="viewport" content="width=device-width, initial-scale=1">
        <title>@title</title>
        <link href="https://cdn.jsdelivr.net/npm/bootstrap@5.1.3/dist/css/bootstrap.min.css" rel="stylesheet">
        <link rel="stylesheet" media="screen" href="@routes.Assets.versioned("stylesheets/main.css")">
        <link rel="shortcut icon" type="image/png" href="@routes.Assets.versioned("images/favicon.png")">
    </head>
    <body>
        <nav class="navbar navbar-expand-lg navbar-dark bg-dark">
            <div class="container">
                <a class="navbar-brand" href="@routes.HomeController.index()">Play App</a>
                <button class="navbar-toggler" type="button" data-bs-toggle="collapse" data-bs-target="#navbarNav">
                    <span class="navbar-toggler-icon"></span>
                </button>
                <div class="collapse navbar-collapse" id="navbarNav">
                    <ul class="navbar-nav">
                        <li class="nav-item">
                            <a class="nav-link" href="@routes.HomeController.index()">Home</a>
                        </li>
                        <li class="nav-item">
                            <a class="nav-link" href="@routes.UserController.list()">Users</a>
                        </li>
                        <li class="nav-item">
                            <a class="nav-link" href="@routes.HomeController.api()">API</a>
                        </li>
                    </ul>
                </div>
            </div>
        </nav>

        <main>
            @content
        </main>

        <footer class="bg-light mt-5 py-4">
            <div class="container">
                <div class="row">
                    <div class="col-md-6">
                        <p>&copy; 2025 Play Framework Application</p>
                    </div>
                    <div class="col-md-6 text-end">
                        <p>Built with <a href="https://www.playframework.com/">Play Framework</a></p>
                    </div>
                </div>
            </div>
        </footer>

        <script src="https://cdn.jsdelivr.net/npm/bootstrap@5.1.3/dist/js/bootstrap.bundle.min.js"></script>
        <script src="@routes.Assets.versioned("javascripts/main.js")" type="text/javascript"></script>
    </body>
</html>

Routing Configuration

# conf/routes
# Routes file

# Home page
GET     /                           controllers.HomeController.index()
GET     /hello/:name                controllers.HomeController.hello(name: String)
GET     /api                        controllers.HomeController.api()
GET     /async                      controllers.HomeController.asyncData()

# User API routes
GET     /users                      controllers.UserController.list()
GET     /users/:id                  controllers.UserController.get(id: Long)
POST    /users                      controllers.UserController.create()
PUT     /users/:id                  controllers.UserController.update(id: Long)
DELETE  /users/:id                  controllers.UserController.delete(id: Long)

# Map static resources from the /public folder to the /assets URL path
GET     /assets/*file               controllers.Assets.versioned(path="/public", file: Asset)

# WebSocket route (optional)
GET     /ws                         controllers.WebSocketController.socket()

# Health check endpoint
GET     /health                     controllers.HealthController.health()

Database Configuration and Evolution

Database Configuration

# conf/application.conf
# Database configuration
slick.dbs.default.profile = "slick.jdbc.H2Profile$"
slick.dbs.default.db.driver = "org.h2.Driver"
slick.dbs.default.db.url = "jdbc:h2:mem:play;DB_CLOSE_DELAY=-1"

# For PostgreSQL
# slick.dbs.default.profile = "slick.jdbc.PostgresProfile$"
# slick.dbs.default.db.driver = "org.postgresql.Driver"
# slick.dbs.default.db.url = "jdbc:postgresql://localhost:5432/playdb"
# slick.dbs.default.db.user = "playuser"
# slick.dbs.default.db.password = "playpass"

# Play configuration
play.http.secret.key = "changeme"
play.http.secret.key = ${?APPLICATION_SECRET}

# CORS configuration
play.filters.cors {
  allowedOrigins = ["http://localhost:3000", "https://yourapp.com"]
  allowedHttpMethods = ["GET", "POST", "PUT", "DELETE", "OPTIONS"]
  allowedHttpHeaders = ["Accept", "Content-Type", "Authorization"]
}

# Evolutions
play.evolutions.db.default.autoApply = true
play.evolutions.db.default.autoApplyDowns = false

Database Evolution Scripts

-- conf/evolutions/default/1.sql
# Users schema

# --- !Ups
CREATE TABLE users (
  id BIGINT AUTO_INCREMENT PRIMARY KEY,
  name VARCHAR(255) NOT NULL,
  email VARCHAR(255) NOT NULL UNIQUE,
  age INT NOT NULL,
  created_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP
);

CREATE INDEX idx_users_email ON users(email);
CREATE INDEX idx_users_created_at ON users(created_at);

INSERT INTO users (name, email, age) VALUES
  ('Alice Johnson', 'alice@example.com', 30),
  ('Bob Smith', 'bob@example.com', 25),
  ('Charlie Brown', 'charlie@example.com', 35);

# --- !Downs
DROP TABLE IF EXISTS users;

Advanced Features

WebSocket Support

// app/controllers/WebSocketController.scala
package controllers

import javax.inject._
import play.api.mvc._
import play.api.libs.streams.ActorFlow
import akka.actor.{Actor, ActorRef, ActorSystem, Props}
import akka.stream.Materializer
import scala.concurrent.{ExecutionContext, Future}

@Singleton
class WebSocketController @Inject()(
  val controllerComponents: ControllerComponents
)(implicit system: ActorSystem, mat: Materializer, ec: ExecutionContext) extends BaseController {

  def socket() = WebSocket.accept[String, String] { request =>
    ActorFlow.actorRef { out =>
      ChatActor.props(out)
    }
  }
}

object ChatActor {
  def props(out: ActorRef) = Props(new ChatActor(out))
}

class ChatActor(out: ActorRef) extends Actor {
  def receive = {
    case msg: String =>
      // Echo the message back with timestamp
      val response = s"Echo: $msg (${System.currentTimeMillis()})"
      out ! response
  }
}

JSON APIs with Validation

// app/controllers/ApiController.scala
package controllers

import javax.inject._
import play.api.mvc._
import play.api.libs.json._
import models.{User, UserRepository}
import scala.concurrent.{ExecutionContext, Future}

@Singleton
class ApiController @Inject()(
  userRepository: UserRepository,
  val controllerComponents: ControllerComponents
)(implicit ec: ExecutionContext) extends BaseController {

  // Custom JSON validator
  case class CreateUserRequest(name: String, email: String, age: Int)
  object CreateUserRequest {
    implicit val format: Format[CreateUserRequest] = Json.format[CreateUserRequest]

    def validate(request: CreateUserRequest): JsResult[CreateUserRequest] = {
      val errors = scala.collection.mutable.ListBuffer[JsonValidationError]()

      if (request.name.trim.isEmpty) {
        errors += JsonValidationError("Name cannot be empty")
      }
      if (!request.email.contains("@")) {
        errors += JsonValidationError("Invalid email format")
      }
      if (request.age < 0 || request.age > 150) {
        errors += JsonValidationError("Age must be between 0 and 150")
      }

      if (errors.nonEmpty) {
        JsError(errors.toSeq.map(_ -> ()))
      } else {
        JsSuccess(request)
      }
    }
  }

  def createUserWithValidation() = Action.async(parse.json) { implicit request =>
    request.body.validate[CreateUserRequest] match {
      case JsSuccess(userRequest, _) =>
        CreateUserRequest.validate(userRequest) match {
          case JsSuccess(validRequest, _) =>
            val user = User(None, validRequest.name, validRequest.email, validRequest.age)
            userRepository.create(user).map { createdUser =>
              Created(Json.toJson(createdUser))
            }
          case JsError(validationErrors) =>
            Future.successful(BadRequest(Json.obj(
              "error" -> "Validation failed",
              "details" -> validationErrors
            )))
        }
      case JsError(parseErrors) =>
        Future.successful(BadRequest(Json.obj(
          "error" -> "Invalid JSON",
          "details" -> parseErrors
        )))
    }
  }

  // Paginated API
  def getUsersPaginated(page: Int = 1, size: Int = 10) = Action.async { implicit request =>
    val offset = (page - 1) * size
    // In a real app, you'd implement pagination in the repository
    userRepository.list().map { allUsers =>
      val users = allUsers.drop(offset).take(size)
      val total = allUsers.length
      val totalPages = math.ceil(total.toDouble / size).toInt

      Ok(Json.obj(
        "data" -> users,
        "pagination" -> Json.obj(
          "page" -> page,
          "size" -> size,
          "total" -> total,
          "totalPages" -> totalPages,
          "hasNext" -> (page < totalPages),
          "hasPrev" -> (page > 1)
        )
      ))
    }
  }
}

Error Handling and Filters

// app/ErrorHandler.scala
import javax.inject._
import play.api.http.DefaultHttpErrorHandler
import play.api.mvc._
import play.api.mvc.Results._
import play.api.libs.json.Json
import scala.concurrent.Future

@Singleton
class ErrorHandler extends DefaultHttpErrorHandler {

  override def onClientError(
    request: RequestHeader,
    statusCode: Int,
    message: String
  ): Future[Result] = {
    val isApiRequest = request.path.startsWith("/api") || 
                      request.contentType.contains("application/json")

    if (isApiRequest) {
      Future.successful(
        Status(statusCode)(Json.obj(
          "error" -> statusCode,
          "message" -> message,
          "path" -> request.path
        ))
      )
    } else {
      Future.successful(
        Status(statusCode)(views.html.error(statusCode, message))
      )
    }
  }

  override def onServerError(
    request: RequestHeader,
    exception: Throwable
  ): Future[Result] = {
    val isApiRequest = request.path.startsWith("/api") || 
                      request.contentType.contains("application/json")

    if (isApiRequest) {
      Future.successful(
        InternalServerError(Json.obj(
          "error" -> 500,
          "message" -> "Internal server error",
          "path" -> request.path
        ))
      )
    } else {
      Future.successful(
        InternalServerError(views.html.error(500, "Internal server error"))
      )
    }
  }
}

// app/filters/LoggingFilter.scala
package filters

import javax.inject._
import akka.stream.Materializer
import play.api.mvc._
import play.api.Logger
import scala.concurrent.{ExecutionContext, Future}

@Singleton
class LoggingFilter @Inject()(
  implicit val mat: Materializer,
  ec: ExecutionContext
) extends Filter {

  private val logger = Logger(this.getClass)

  def apply(
    nextFilter: RequestHeader => Future[Result]
  )(requestHeader: RequestHeader): Future[Result] = {

    val startTime = System.currentTimeMillis()

    nextFilter(requestHeader).map { result =>
      val endTime = System.currentTimeMillis()
      val duration = endTime - startTime

      logger.info(
        s"${requestHeader.method} ${requestHeader.uri} -> ${result.header.status} (${duration}ms)"
      )

      result
    }
  }
}

Testing Play Applications

Unit Testing Controllers

// test/controllers/UserControllerSpec.scala
package controllers

import org.scalatestplus.play._
import org.scalatestplus.play.guice._
import play.api.test._
import play.api.test.Helpers._
import play.api.libs.json._
import models.{User, UserRepository}
import org.mockito.Mockito._
import org.mockito.ArgumentMatchers._
import org.scalatestplus.mockito.MockitoSugar
import scala.concurrent.Future

class UserControllerSpec extends PlaySpec with GuiceOneAppPerTest with MockitoSugar {

  "UserController" should {

    "return list of users" in {
      val mockRepository = mock[UserRepository]
      val users = Seq(
        User(Some(1), "Alice", "alice@example.com", 30),
        User(Some(2), "Bob", "bob@example.com", 25)
      )
      when(mockRepository.list()).thenReturn(Future.successful(users))

      val controller = new UserController(mockRepository, stubControllerComponents())
      val result = controller.list().apply(FakeRequest(GET, "/users"))

      status(result) mustBe OK
      contentType(result) mustBe Some("application/json")
      val json = contentAsJson(result)
      (json \\ "name").map(_.as[String]) must contain allOf("Alice", "Bob")
    }

    "create a new user" in {
      val mockRepository = mock[UserRepository]
      val user = User(Some(1), "Charlie", "charlie@example.com", 35)
      when(mockRepository.create(any[User])).thenReturn(Future.successful(user))

      val controller = new UserController(mockRepository, stubControllerComponents())
      val json = Json.obj(
        "name" -> "Charlie",
        "email" -> "charlie@example.com",
        "age" -> 35
      )
      val request = FakeRequest(POST, "/users")
        .withHeaders("Content-Type" -> "application/json")
        .withBody(json)

      val result = controller.create().apply(request)

      status(result) mustBe CREATED
      contentType(result) mustBe Some("application/json")
      (contentAsJson(result) \ "name").as[String] mustBe "Charlie"
    }

    "return 404 for non-existent user" in {
      val mockRepository = mock[UserRepository]
      when(mockRepository.get(999L)).thenReturn(Future.successful(None))

      val controller = new UserController(mockRepository, stubControllerComponents())
      val result = controller.get(999L).apply(FakeRequest(GET, "/users/999"))

      status(result) mustBe NOT_FOUND
      contentType(result) mustBe Some("application/json")
      (contentAsJson(result) \ "error").as[String] mustBe "User not found"
    }
  }
}

// test/integration/UserIntegrationSpec.scala
package integration

import org.scalatestplus.play._
import org.scalatestplus.play.guice._
import play.api.test._
import play.api.test.Helpers._
import play.api.libs.json._

class UserIntegrationSpec extends PlaySpec with GuiceOneServerPerSuite {

  "User API" should {

    "handle full CRUD operations" in {
      val userJson = Json.obj(
        "name" -> "Integration Test User",
        "email" -> "integration@example.com",
        "age" -> 25
      )

      // Create user
      val createRequest = FakeRequest(POST, "/users")
        .withHeaders("Content-Type" -> "application/json")
        .withBody(userJson)

      val createResult = route(app, createRequest).get
      status(createResult) mustBe CREATED
      val createdUser = contentAsJson(createResult)
      val userId = (createdUser \ "id").as[Long]

      // Get user
      val getResult = route(app, FakeRequest(GET, s"/users/$userId")).get
      status(getResult) mustBe OK
      (contentAsJson(getResult) \ "name").as[String] mustBe "Integration Test User"

      // Update user
      val updateJson = Json.obj(
        "name" -> "Updated User",
        "email" -> "updated@example.com",
        "age" -> 30
      )
      val updateRequest = FakeRequest(PUT, s"/users/$userId")
        .withHeaders("Content-Type" -> "application/json")
        .withBody(updateJson)

      val updateResult = route(app, updateRequest).get
      status(updateResult) mustBe OK
      (contentAsJson(updateResult) \ "name").as[String] mustBe "Updated User"

      // Delete user
      val deleteResult = route(app, FakeRequest(DELETE, s"/users/$userId")).get
      status(deleteResult) mustBe OK

      // Verify deletion
      val getDeletedResult = route(app, FakeRequest(GET, s"/users/$userId")).get
      status(getDeletedResult) mustBe NOT_FOUND
    }
  }
}

Production Deployment

Configuration for Production

# conf/application.prod.conf
include "application.conf"

# Production database
slick.dbs.default.profile = "slick.jdbc.PostgresProfile$"
slick.dbs.default.db.driver = "org.postgresql.Driver"
slick.dbs.default.db.url = ${DATABASE_URL}
slick.dbs.default.db.connectionPool = "HikariCP"
slick.dbs.default.db.maximumPoolSize = 20
slick.dbs.default.db.minimumIdle = 5

# Security
play.http.secret.key = ${APPLICATION_SECRET}
play.filters.csrf.cookie.secure = true
play.filters.hosts.allowed = [".yourapp.com", "yourapp.com"]

# Evolutions
play.evolutions.db.default.autoApply = false
play.evolutions.db.default.autoApplyDowns = false

# Logging
logger.root = WARN
logger.play = INFO
logger.application = INFO

Docker Configuration

# Dockerfile
FROM eclipse-temurin:11-jre-alpine

# Install dumb-init for proper signal handling
RUN apk add --no-cache dumb-init

# Create non-root user
RUN addgroup -g 1001 playuser && \
    adduser -D -s /bin/sh -u 1001 -G playuser playuser

# Set working directory
WORKDIR /app

# Copy application
COPY target/universal/stage .

# Change ownership
RUN chown -R playuser:playuser /app

# Switch to non-root user
USER playuser

# Expose port
EXPOSE 9000

# Health check
HEALTHCHECK --interval=30s --timeout=10s --start-period=60s --retries=3 \
  CMD curl -f http://localhost:9000/health || exit 1

# Start application
ENTRYPOINT ["dumb-init", "--"]
CMD ["bin/my-play-app", "-Dconfig.resource=application.prod.conf"]

Conclusion

The Play Framework provides a comprehensive platform for building modern web applications with Scala. Key advantages include:

Developer Experience:

  • Hot reloading for rapid development
  • Type-safe templates and routing
  • Excellent error messages and debugging

Architecture:

  • Clean MVC separation
  • Reactive and non-blocking by default
  • Built on Akka for high concurrency

Features:

  • Built-in JSON support and validation
  • WebSocket and real-time capabilities
  • Database integration with Slick
  • Comprehensive testing support

Production Ready:

  • Mature ecosystem and community
  • Excellent performance characteristics
  • Docker and cloud deployment support
  • Comprehensive monitoring and logging

Play Framework is an excellent choice for building scalable web applications, REST APIs, and reactive systems with Scala, offering both developer productivity and production reliability.