本文目录导读:

我来详细介绍Symfony表单与地理位置的结合使用。
基础设置
安装依赖包
composer require lexik/form-filter-bundle composer require geoip2/geoip2 composer require stof/doctrine-extensions-bundle
配置地理信息服务
# config/packages/lexik_form_filter.yaml
lexik_form_filter:
listeners:
force_parameter_type: false
地理位置表单字段类型
创建地理位置表单类型
// src/Form/Type/GeolocationType.php
namespace App\Form\Type;
use Symfony\Component\Form\AbstractType;
use Symfony\Component\Form\Extension\Core\Type\HiddenType;
use Symfony\Component\Form\Extension\Core\Type\NumberType;
use Symfony\Component\Form\FormBuilderInterface;
use Symfony\Component\OptionsResolver\OptionsResolver;
use Symfony\Component\Form\CallbackTransformer;
class GeolocationType extends AbstractType
{
public function buildForm(FormBuilderInterface $builder, array $options)
{
$builder
->add('latitude', HiddenType::class, [
'required' => false,
'attr' => ['class' => 'latitude-input']
])
->add('longitude', HiddenType::class, [
'required' => false,
'attr' => ['class' => 'longitude-input']
])
->add('address', null, [
'required' => false,
'label' => '地址',
'attr' => [
'placeholder' => '输入地址搜索',
'class' => 'geolocation-address'
]
]);
// 添加数据转换
$builder->addModelTransformer(new CallbackTransformer(
function ($location) {
// 实体到表单
if ($location instanceof Location) {
return [
'latitude' => $location->getLatitude(),
'longitude' => $location->getLongitude(),
'address' => $location->getAddress()
];
}
return ['latitude' => null, 'longitude' => null, 'address' => null];
},
function ($data) {
// 表单到实体
if (is_array($data) && isset($data['latitude'])) {
$location = new Location();
$location->setLatitude($data['latitude']);
$location->setLongitude($data['longitude']);
$location->setAddress($data['address'] ?? '');
return $location;
}
return null;
}
));
}
public function configureOptions(OptionsResolver $resolver)
{
$resolver->setDefaults([
'data_class' => null,
'compound' => true,
]);
}
}
实体类设计
地理位置实体
// src/Entity/Location.php
namespace App\Entity;
use Doctrine\ORM\Mapping as ORM;
use Symfony\Component\Validator\Constraints as Assert;
#[ORM\Embeddable]
class Location
{
#[ORM\Column(type: 'decimal', precision: 10, scale: 7, nullable: true)]
#[Assert\Range(min: -90, max: 90)]
private ?float $latitude = null;
#[ORM\Column(type: 'decimal', precision: 10, scale: 7, nullable: true)]
#[Assert\Range(min: -180, max: 180)]
private ?float $longitude = null;
#[ORM\Column(type: 'string', length: 255, nullable: true)]
private ?string $address = null;
// Getters and Setters
public function getLatitude(): ?float
{
return $this->latitude;
}
public function setLatitude(?float $latitude): self
{
$this->latitude = $latitude;
return $this;
}
public function getLongitude(): ?float
{
return $this->longitude;
}
public function setLongitude(?float $longitude): self
{
$this->longitude = $longitude;
return $this;
}
public function getAddress(): ?string
{
return $this->address;
}
public function setAddress(?string $address): self
{
$this->address = $address;
return $this;
}
public function getCoordinates(): ?array
{
if ($this->latitude && $this->longitude) {
return ['lat' => $this->latitude, 'lng' => $this->longitude];
}
return null;
}
}
主实体示例
// src/Entity/Place.php
namespace App\Entity;
use Doctrine\ORM\Mapping as ORM;
#[ORM\Entity]
class Place
{
#[ORM\Id]
#[ORM\GeneratedValue]
#[ORM\Column]
private ?int $id = null;
#[ORM\Column(length: 255)]
private ?string $name = null;
#[ORM\Embedded(class: Location::class)]
private ?Location $location = null;
public function __construct()
{
$this->location = new Location();
}
// Getters and Setters
public function getId(): ?int
{
return $this->id;
}
public function getName(): ?string
{
return $this->name;
}
public function setName(string $name): self
{
$this->name = $name;
return $this;
}
public function getLocation(): ?Location
{
return $this->location;
}
public function setLocation(Location $location): self
{
$this->location = $location;
return $this;
}
}
控制器实现
PlaceController
// src/Controller/PlaceController.php
namespace App\Controller;
use App\Entity\Place;
use App\Form\Type\PlaceType;
use App\Repository\PlaceRepository;
use App\Service\GeolocationService;
use Doctrine\ORM\EntityManagerInterface;
use Symfony\Bundle\FrameworkBundle\Controller\AbstractController;
use Symfony\Component\HttpFoundation\Request;
use Symfony\Component\HttpFoundation\Response;
use Symfony\Component\Routing\Annotation\Route;
#[Route('/places')]
class PlaceController extends AbstractController
{
#[Route('/new', name: 'place_new', methods: ['GET', 'POST'])]
public function new(Request $request, GeolocationService $geolocationService, EntityManagerInterface $em): Response
{
$place = new Place();
$form = $this->createForm(PlaceType::class, $place);
$form->handleRequest($request);
if ($form->isSubmitted() && $form->isValid()) {
// 验证地理位置
$location = $place->getLocation();
if ($location->getLatitude() && $location->getLongitude()) {
// 反向地理编码获取地址
$address = $geolocationService->reverseGeocode(
$location->getLatitude(),
$location->getLongitude()
);
$location->setAddress($address);
}
$em->persist($place);
$em->flush();
$this->addFlash('success', '地点创建成功');
return $this->redirectToRoute('place_index');
}
return $this->render('place/new.html.twig', [
'form' => $form->createView(),
]);
}
#[Route('/search', name: 'place_search', methods: ['GET'])]
public function search(Request $request, PlaceRepository $placeRepository): Response
{
$form = $this->createForm(PlaceSearchType::class);
$form->handleRequest($request);
$places = [];
if ($form->isSubmitted() && $form->isValid()) {
$data = $form->getData();
$places = $placeRepository->findNearby(
$data['latitude'],
$data['longitude'],
$data['radius'] ?? 10
);
}
return $this->render('place/search.html.twig', [
'form' => $form->createView(),
'places' => $places,
]);
}
}
搜索表单与距离查询
搜索表单类型
// src/Form/Type/PlaceSearchType.php
namespace App\Form\Type;
use Symfony\Component\Form\AbstractType;
use Symfony\Component\Form\Extension\Core\Type\NumberType;
use Symfony\Component\Form\Extension\Core\Type\RangeType;
use Symfony\Component\Form\FormBuilderInterface;
use Symfony\Component\OptionsResolver\OptionsResolver;
class PlaceSearchType extends AbstractType
{
public function buildForm(FormBuilderInterface $builder, array $options)
{
$builder
->add('latitude', NumberType::class, [
'required' => true,
'label' => '纬度'
])
->add('longitude', NumberType::class, [
'required' => true,
'label' => '经度'
])
->add('radius', RangeType::class, [
'required' => true,
'label' => '搜索半径 (公里)',
'attr' => [
'min' => 1,
'max' => 50,
'step' => 1
]
]);
}
}
带距离查询的Repository
// src/Repository/PlaceRepository.php
namespace App\Repository;
use App\Entity\Place;
use Doctrine\Bundle\DoctrineBundle\Repository\ServiceEntityRepository;
use Doctrine\Persistence\ManagerRegistry;
class PlaceRepository extends ServiceEntityRepository
{
public function __construct(ManagerRegistry $registry)
{
parent::__construct($registry, Place::class);
}
public function findNearby(float $latitude, float $longitude, float $radius = 10): array
{
$earthRadius = 6371; // 地球半径(公里)
$sql = "
SELECT p,
( :earthRadius * ACOS(
COS(RADIANS(:latitude)) *
COS(RADIANS(p.location.latitude)) *
COS(RADIANS(p.location.longitude) - RADIANS(:longitude)) +
SIN(RADIANS(:latitude)) *
SIN(RADIANS(p.location.latitude))
) ) AS distance
FROM App\Entity\Place p
WHERE p.location.latitude IS NOT NULL
AND p.location.longitude IS NOT NULL
HAVING distance <= :radius
ORDER BY distance ASC
";
$query = $this->getEntityManager()
->createQuery($sql)
->setParameter('earthRadius', $earthRadius)
->setParameter('latitude', $latitude)
->setParameter('longitude', $longitude)
->setParameter('radius', $radius);
return $query->getResult();
}
}
JavaScript前端集成
前端JavaScript
// assets/js/geolocation.js
class GeolocationHelper {
constructor(formId) {
this.form = document.getElementById(formId);
this.addressInput = this.form.querySelector('.geolocation-address');
this.latitudeInput = this.form.querySelector('.latitude-input');
this.longitudeInput = this.form.querySelector('.longitude-input');
this.initAutocomplete();
this.initGeoLocation();
}
initAutocomplete() {
// 使用Google Maps Autocomplete
if (typeof google !== 'undefined') {
const autocomplete = new google.maps.places.Autocomplete(this.addressInput);
autocomplete.addListener('place_changed', () => {
const place = autocomplete.getPlace();
if (place.geometry) {
this.latitudeInput.value = place.geometry.location.lat();
this.longitudeInput.value = place.geometry.location.lng();
this.addressInput.value = place.formatted_address;
}
});
}
}
initGeoLocation() {
if (navigator.geolocation) {
const getLocationBtn = document.getElementById('get-location-btn');
if (getLocationBtn) {
getLocationBtn.addEventListener('click', () => {
navigator.geolocation.getCurrentPosition(
(position) => {
this.latitudeInput.value = position.coords.latitude;
this.longitudeInput.value = position.coords.longitude;
this.reverseGeocode(
position.coords.latitude,
position.coords.longitude
);
},
(error) => {
console.error('GeoLocation Error:', error);
}
);
});
}
}
}
async reverseGeocode(lat, lng) {
try {
const response = await fetch(`/api/reverse-geocode?lat=${lat}&lng=${lng}`);
const data = await response.json();
if (data.address) {
this.addressInput.value = data.address;
}
} catch (error) {
console.error('Reverse Geocode Error:', error);
}
}
}
// 初始化
document.addEventListener('DOMContentLoaded', () => {
new GeolocationHelper('place_form');
});
模板文件
Twig模板
{# templates/place/new.html.twig #}
{% extends 'base.html.twig' %}
{% block stylesheets %}
{{ parent() }}
<link rel="stylesheet" href="https://unpkg.com/leaflet@1.7.1/dist/leaflet.css" />
{% endblock %}
{% block body %}
<div class="container mt-4">
<h1>创建新地点</h1>
{{ form_start(form) }}
<div class="card">
<div class="card-body">
<div class="mb-3">
{{ form_label(form.name) }}
{{ form_widget(form.name) }}
</div>
<div class="mb-3">
{{ form_label(form.location) }}
<div id="map" style="height: 400px;" class="mb-3"></div>
{{ form_widget(form.location) }}
<button type="button" id="get-location-btn" class="btn btn-secondary mt-2">
获取当前位置
</button>
</div>
</div>
</div>
<button type="submit" class="btn btn-primary mt-3">保存</button>
{{ form_end(form) }}
</div>
{% endblock %}
{% block javascripts %}
{{ parent() }}
<script src="https://unpkg.com/leaflet@1.7.1/dist/leaflet.js"></script>
<script src="https://maps.googleapis.com/maps/api/js?key=YOUR_API_KEY&libraries=places"></script>
<script src="{{ asset('js/geolocation.js') }}"></script>
<script>
// 初始化Leaflet地图
var map = L.map('map').setView([39.9042, 116.4074], 10);
L.tileLayer('https://{s}.tile.openstreetmap.org/{z}/{x}/{y}.png', {
attribution: '© OpenStreetMap contributors'
}).addTo(map);
var marker;
map.on('click', function(e) {
if (marker) {
map.removeLayer(marker);
}
marker = L.marker(e.latlng).addTo(map);
document.querySelector('.latitude-input').value = e.latlng.lat;
document.querySelector('.longitude-input').value = e.latlng.lng;
});
</script>
{% endblock %}
API服务
反向地理编码服务
// src/Service/GeolocationService.php
namespace App\Service;
use Symfony\Contracts\HttpClient\HttpClientInterface;
class GeolocationService
{
private $client;
public function __construct(HttpClientInterface $client)
{
$this->client = $client;
}
public function reverseGeocode(float $latitude, float $longitude): ?string
{
try {
$response = $this->client->request('GET', 'https://nominatim.openstreetmap.org/reverse', [
'query' => [
'lat' => $latitude,
'lon' => $longitude,
'format' => 'json',
'addressdetails' => 1,
]
]);
$data = $response->toArray();
return $data['display_name'] ?? null;
} catch (\Exception $e) {
return null;
}
}
public function geocode(string $address): ?array
{
try {
$response = $this->client->request('GET', 'https://nominatim.openstreetmap.org/search', [
'query' => [
'q' => $address,
'format' => 'json',
'limit' => 1,
]
]);
$data = $response->toArray();
if (!empty($data)) {
return [
'latitude' => $data[0]['lat'],
'longitude' => $data[0]['lon'],
'address' => $data[0]['display_name']
];
}
return null;
} catch (\Exception $e) {
return null;
}
}
}
自定义表单主题
# config/packages/twig.yaml
twig:
form_themes:
- 'form/geolocation_fields.html.twig'
{# templates/form/geolocation_fields.html.twig #}
{% block geolocation_widget %}
<div class="geolocation-widget">
<div class="mb-3">
{{ form_widget(form.address, {'attr': {'class': 'form-control geolocation-address', 'placeholder': '输入地址...'}}) }}
</div>
<div class="row">
<div class="col-md-6">
{{ form_label(form.latitude, '纬度') }}
{{ form_widget(form.latitude, {'attr': {'class': 'form-control latitude-input'}}) }}
</div>
<div class="col-md-6">
{{ form_label(form.longitude, '经度') }}
{{ form_widget(form.longitude, {'attr': {'class': 'form-control longitude-input'}}) }}
</div>
</div>
<div id="map-container" style="height: 300px;" class="mt-3"></div>
</div>
{% endblock %}
验证约束
// src/Validator/Constraints/ValidLocation.php
namespace App\Validator\Constraints;
use Symfony\Component\Validator\Constraint;
#[Attribute]
class ValidLocation extends Constraint
{
public $message = '请提供有效的地理位置坐标。';
public $latitudeRangeMessage = '纬度值必须在 -90 到 90 之间。';
public $longitudeRangeMessage = '经度值必须在 -180 到 180 之间。';
}
// src/Validator/Constraints/ValidLocationValidator.php
namespace App\Validator\Constraints;
use Symfony\Component\Validator\Constraint;
use Symfony\Component\Validator\ConstraintValidator;
class ValidLocationValidator extends ConstraintValidator
{
public function validate($value, Constraint $constraint)
{
if (!$constraint instanceof ValidLocation) {
return;
}
if ($value === null || $value === '') {
return;
}
$latitude = $value->getLatitude();
$longitude = $value->getLongitude();
if ($latitude !== null && ($latitude < -90 || $latitude > 90)) {
$this->context->buildViolation($constraint->latitudeRangeMessage)
->atPath('latitude')
->addViolation();
}
if ($longitude !== null && ($longitude < -180 || $longitude > 180)) {
$this->context->buildViolation($constraint->longitudeRangeMessage)
->atPath('longitude')
->addViolation();
}
}
}
最佳实践建议
- 使用覆盖嵌入(Embedded):将地理位置作为值对象处理
- 前端验证:确保前端也进行坐标验证
- 数据库索引:为经纬度字段创建索引
- API限流:使用外部地理编码服务时注意API调用限制
- 缓存:缓存地理编码结果
- 渐进增强:确保无JavaScript时也能使用基本功能
这样你就可以在Symfony项目中实现完整的地理位置表单功能了!